@checkflow/sdk 1.0.1

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,4613 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var jsxRuntime = require('react/jsx-runtime');
6
+ var react = require('react');
7
+
8
+ /**
9
+ * CheckFlow API Client
10
+ * Handles all communication with the CheckFlow backend
11
+ */
12
+ class APIClient {
13
+ constructor(options) {
14
+ this.apiUrl = options.apiUrl.replace(/\/$/, '');
15
+ this.apiKey = options.apiKey;
16
+ this.projectId = options.projectId;
17
+ this.timeout = options.timeout || 30000;
18
+ this.debug = options.debug || false;
19
+ }
20
+ getProjectId() {
21
+ return this.projectId;
22
+ }
23
+ getBaseUrl() {
24
+ return this.apiUrl;
25
+ }
26
+ log(...args) {
27
+ if (this.debug) {
28
+ console.log('[CheckFlow]', ...args);
29
+ }
30
+ }
31
+ async get(endpoint, headers) {
32
+ return this.request('GET', endpoint, undefined, headers);
33
+ }
34
+ async post(endpoint, data, headers) {
35
+ return this.request('POST', endpoint, data, headers);
36
+ }
37
+ async put(endpoint, data, headers) {
38
+ return this.request('PUT', endpoint, data, headers);
39
+ }
40
+ async request(method, endpoint, data, headers) {
41
+ const url = `${this.apiUrl}${endpoint}`;
42
+ const controller = new AbortController();
43
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
44
+ try {
45
+ this.log(`${method} ${endpoint}`, data);
46
+ const response = await fetch(url, {
47
+ method,
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'X-API-Key': this.apiKey,
51
+ ...headers,
52
+ },
53
+ body: data ? JSON.stringify(data) : undefined,
54
+ signal: controller.signal,
55
+ });
56
+ clearTimeout(timeoutId);
57
+ const responseData = await response.json();
58
+ if (!response.ok) {
59
+ this.log('API Error:', response.status, responseData);
60
+ return {
61
+ success: false,
62
+ error: responseData.detail || responseData.message || 'Request failed',
63
+ statusCode: response.status,
64
+ };
65
+ }
66
+ this.log('API Response:', responseData);
67
+ return {
68
+ success: true,
69
+ data: responseData,
70
+ statusCode: response.status,
71
+ };
72
+ }
73
+ catch (error) {
74
+ clearTimeout(timeoutId);
75
+ if (error.name === 'AbortError') {
76
+ return {
77
+ success: false,
78
+ error: 'Request timeout',
79
+ statusCode: 408,
80
+ };
81
+ }
82
+ this.log('API Error:', error);
83
+ return {
84
+ success: false,
85
+ error: error.message || 'Network error',
86
+ statusCode: 0,
87
+ };
88
+ }
89
+ }
90
+ /**
91
+ * Submit feedback to the backend
92
+ */
93
+ async submitFeedback(feedback, capture, user, sessionRecording, annotations) {
94
+ const payload = {
95
+ title: feedback.title,
96
+ description: feedback.description || '',
97
+ type: feedback.type,
98
+ priority: feedback.priority || 'medium',
99
+ tags: feedback.tags || [],
100
+ url: capture?.context?.url || window.location.href,
101
+ // Context data
102
+ viewport_width: capture?.context?.viewport?.width,
103
+ viewport_height: capture?.context?.viewport?.height,
104
+ user_agent: capture?.context?.userAgent || navigator.userAgent,
105
+ browser: capture?.context?.browser?.name,
106
+ browser_version: capture?.context?.browser?.version,
107
+ os: capture?.context?.os?.name,
108
+ os_version: capture?.context?.os?.version,
109
+ locale: capture?.context?.language || navigator.language,
110
+ timezone: capture?.context?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
111
+ // Capture data
112
+ console_logs: capture?.consoleLogs || [],
113
+ network_logs: capture?.networkLogs || [],
114
+ performance_metrics: capture?.performance || {},
115
+ // Screenshot (base64) - included directly in payload
116
+ screenshot_base64: capture?.screenshot || null,
117
+ // Session recording events
118
+ session_recording_events: sessionRecording?.events || null,
119
+ session_id: sessionRecording?.sessionId || null,
120
+ // Annotations data
121
+ annotations: annotations || null,
122
+ // User data
123
+ reporter_email: user?.email,
124
+ reporter_name: user?.name,
125
+ // Custom metadata
126
+ custom_fields: {
127
+ ...feedback.metadata,
128
+ sdk_version: '__SDK_VERSION__',
129
+ },
130
+ };
131
+ const response = await this.request('POST', '/api/v1/capture/sdk/feedback', payload);
132
+ if (response.success && response.data) {
133
+ return {
134
+ success: true,
135
+ feedbackId: response.data.id,
136
+ shortId: response.data.short_id,
137
+ screenshotUrl: response.data.screenshot_url,
138
+ videoUrl: response.data.video_url,
139
+ message: 'Feedback submitted successfully',
140
+ };
141
+ }
142
+ return {
143
+ success: false,
144
+ error: response.error || 'Failed to submit feedback',
145
+ };
146
+ }
147
+ /**
148
+ * Upload screenshot as attachment
149
+ */
150
+ async uploadScreenshot(feedbackId, base64Image) {
151
+ // Convert base64 to blob
152
+ const byteString = atob(base64Image.split(',')[1] || base64Image);
153
+ const mimeType = base64Image.match(/data:(.*?);/)?.[1] || 'image/png';
154
+ const ab = new ArrayBuffer(byteString.length);
155
+ const ia = new Uint8Array(ab);
156
+ for (let i = 0; i < byteString.length; i++) {
157
+ ia[i] = byteString.charCodeAt(i);
158
+ }
159
+ const blob = new Blob([ab], { type: mimeType });
160
+ const formData = new FormData();
161
+ formData.append('file', blob, `screenshot-${Date.now()}.png`);
162
+ try {
163
+ const response = await fetch(`${this.apiUrl}/api/v1/comments/attachments/upload?feedback_id=${feedbackId}`, {
164
+ method: 'POST',
165
+ headers: {
166
+ 'X-API-Key': this.apiKey,
167
+ },
168
+ body: formData,
169
+ });
170
+ if (response.ok) {
171
+ return { success: true, data: await response.json() };
172
+ }
173
+ return { success: false, error: 'Failed to upload screenshot' };
174
+ }
175
+ catch (error) {
176
+ return { success: false, error: error.message };
177
+ }
178
+ }
179
+ /**
180
+ * Send SDK capture data to backend for processing
181
+ * Aligns with backend SDKCapturePayload schema
182
+ */
183
+ async sendCapture(capture, sessionId) {
184
+ // Format payload to match backend SDKCapturePayload schema
185
+ const payload = {
186
+ url: capture.context.url,
187
+ page_title: capture.context.title,
188
+ referrer: capture.context.referrer,
189
+ screenshot_base64: capture.screenshot,
190
+ console_logs: capture.consoleLogs?.map(log => ({
191
+ level: log.level,
192
+ message: log.message,
193
+ timestamp: log.timestamp,
194
+ source: log.stack?.split('\n')[0],
195
+ })) || [],
196
+ network_requests: capture.networkLogs?.map(req => ({
197
+ url: req.url,
198
+ method: req.method,
199
+ status: req.status,
200
+ status_text: req.statusText,
201
+ duration_ms: req.duration,
202
+ size: req.size,
203
+ resource_type: req.type,
204
+ timestamp: req.timestamp,
205
+ is_failed: !!req.error,
206
+ failure_text: req.error,
207
+ })) || [],
208
+ performance_data: capture.performance ? {
209
+ navigation_start: 0,
210
+ dom_content_loaded: capture.performance.domContentLoaded,
211
+ load_event: capture.performance.loadTime,
212
+ first_contentful_paint: capture.performance.firstContentfulPaint,
213
+ largest_contentful_paint: capture.performance.largestContentfulPaint,
214
+ cumulative_layout_shift: capture.performance.cumulativeLayoutShift,
215
+ first_input_delay: capture.performance.firstInputDelay,
216
+ } : undefined,
217
+ javascript_errors: [],
218
+ viewport: capture.context.viewport,
219
+ user_agent: capture.context.userAgent,
220
+ language: capture.context.language,
221
+ timezone: capture.context.timezone,
222
+ session_id: sessionId,
223
+ custom_data: {
224
+ browser: capture.context.browser,
225
+ os: capture.context.os,
226
+ device_type: capture.context.deviceType,
227
+ screen_resolution: capture.context.screenResolution,
228
+ pixel_ratio: capture.context.pixelRatio,
229
+ },
230
+ };
231
+ return this.request('POST', '/api/v1/capture/sdk/capture', payload);
232
+ }
233
+ /**
234
+ * Upload just a screenshot (simpler endpoint)
235
+ */
236
+ async uploadScreenshotOnly(screenshotBase64, url) {
237
+ return this.request('POST', '/api/v1/capture/sdk/screenshot', {
238
+ screenshot_base64: screenshotBase64,
239
+ url: url,
240
+ });
241
+ }
242
+ /**
243
+ * Save session recording to backend
244
+ * Aligns with backend /sdk/recording endpoint
245
+ */
246
+ async saveSessionRecording(recording) {
247
+ return this.request('POST', '/api/v1/capture/sdk/recording', {
248
+ events: recording.events,
249
+ session_id: recording.sessionId,
250
+ start_url: recording.startUrl,
251
+ end_url: recording.endUrl,
252
+ duration_seconds: recording.durationSeconds,
253
+ });
254
+ }
255
+ /**
256
+ * Report an error to the backend
257
+ */
258
+ async reportError(error) {
259
+ return this.request('POST', '/api/v1/capture/sdk/error', {
260
+ ...error,
261
+ timestamp: new Date().toISOString(),
262
+ url: window.location.href,
263
+ user_agent: navigator.userAgent,
264
+ });
265
+ }
266
+ /**
267
+ * Check API health
268
+ */
269
+ async healthCheck() {
270
+ const response = await this.request('GET', '/health');
271
+ return response.success;
272
+ }
273
+ /**
274
+ * Update project ID
275
+ */
276
+ setProjectId(projectId) {
277
+ this.projectId = projectId;
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Context Capture Module
283
+ * Handles screenshot capture, console logs, network logs, and performance metrics
284
+ */
285
+ // ==================== Console Capture ====================
286
+ class ConsoleCapture {
287
+ constructor(maxEntries = 100) {
288
+ this.entries = [];
289
+ this.originalMethods = {};
290
+ this.isCapturing = false;
291
+ this.maxEntries = maxEntries;
292
+ }
293
+ start() {
294
+ if (this.isCapturing)
295
+ return;
296
+ this.isCapturing = true;
297
+ const levels = [
298
+ 'log', 'info', 'warn', 'error', 'debug'
299
+ ];
300
+ levels.forEach((level) => {
301
+ const originalMethod = console[level];
302
+ if (typeof originalMethod === 'function') {
303
+ this.originalMethods[level] = originalMethod.bind(console);
304
+ console[level] = (...args) => {
305
+ this.addEntry(level, args);
306
+ if (this.originalMethods[level]) {
307
+ this.originalMethods[level](...args);
308
+ }
309
+ };
310
+ }
311
+ });
312
+ }
313
+ stop() {
314
+ if (!this.isCapturing)
315
+ return;
316
+ this.isCapturing = false;
317
+ Object.entries(this.originalMethods).forEach(([level, method]) => {
318
+ console[level] = method;
319
+ });
320
+ this.originalMethods = {};
321
+ }
322
+ addEntry(level, args) {
323
+ const message = args
324
+ .map((arg) => {
325
+ if (typeof arg === 'object') {
326
+ try {
327
+ return JSON.stringify(arg, null, 2);
328
+ }
329
+ catch {
330
+ return String(arg);
331
+ }
332
+ }
333
+ return String(arg);
334
+ })
335
+ .join(' ');
336
+ const entry = {
337
+ level,
338
+ message: message.substring(0, 5000), // Limit message length
339
+ timestamp: new Date().toISOString(),
340
+ };
341
+ // Capture stack for errors
342
+ if (level === 'error') {
343
+ const stack = new Error().stack;
344
+ if (stack) {
345
+ entry.stack = stack.split('\n').slice(3).join('\n');
346
+ }
347
+ }
348
+ this.entries.push(entry);
349
+ // Keep only recent entries
350
+ if (this.entries.length > this.maxEntries) {
351
+ this.entries.shift();
352
+ }
353
+ }
354
+ getEntries() {
355
+ return [...this.entries];
356
+ }
357
+ clear() {
358
+ this.entries = [];
359
+ }
360
+ }
361
+ // ==================== Network Capture ====================
362
+ class NetworkCapture {
363
+ constructor(maxEntries = 100) {
364
+ this.entries = [];
365
+ this.isCapturing = false;
366
+ this.maxEntries = maxEntries;
367
+ }
368
+ start() {
369
+ if (this.isCapturing)
370
+ return;
371
+ this.isCapturing = true;
372
+ // Intercept fetch - bind to window to avoid "Illegal invocation"
373
+ this.originalFetch = window.fetch.bind(window);
374
+ const self = this;
375
+ window.fetch = async function (input, init) {
376
+ const startTime = performance.now();
377
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
378
+ const method = init?.method || 'GET';
379
+ try {
380
+ const response = await self.originalFetch(input, init);
381
+ const duration = performance.now() - startTime;
382
+ self.addEntry({
383
+ url,
384
+ method,
385
+ status: response.status,
386
+ statusText: response.statusText,
387
+ duration: Math.round(duration),
388
+ type: response.headers.get('content-type') || undefined,
389
+ timestamp: new Date().toISOString(),
390
+ });
391
+ return response;
392
+ }
393
+ catch (error) {
394
+ const duration = performance.now() - startTime;
395
+ self.addEntry({
396
+ url,
397
+ method,
398
+ duration: Math.round(duration),
399
+ timestamp: new Date().toISOString(),
400
+ error: error.message,
401
+ });
402
+ throw error;
403
+ }
404
+ };
405
+ // Intercept XMLHttpRequest
406
+ this.originalXHROpen = XMLHttpRequest.prototype.open;
407
+ this.originalXHRSend = XMLHttpRequest.prototype.send;
408
+ XMLHttpRequest.prototype.open = function (method, url, ...rest) {
409
+ this._checkflow = {
410
+ method,
411
+ url: url.toString(),
412
+ startTime: 0,
413
+ };
414
+ return self.originalXHROpen.apply(this, [method, url, ...rest]);
415
+ };
416
+ XMLHttpRequest.prototype.send = function (body) {
417
+ const xhr = this;
418
+ const meta = xhr._checkflow;
419
+ if (meta) {
420
+ meta.startTime = performance.now();
421
+ xhr.addEventListener('loadend', () => {
422
+ const duration = performance.now() - meta.startTime;
423
+ self.addEntry({
424
+ url: meta.url,
425
+ method: meta.method,
426
+ status: xhr.status,
427
+ statusText: xhr.statusText,
428
+ duration: Math.round(duration),
429
+ type: xhr.getResponseHeader('content-type') || undefined,
430
+ timestamp: new Date().toISOString(),
431
+ });
432
+ });
433
+ xhr.addEventListener('error', () => {
434
+ const duration = performance.now() - meta.startTime;
435
+ self.addEntry({
436
+ url: meta.url,
437
+ method: meta.method,
438
+ duration: Math.round(duration),
439
+ timestamp: new Date().toISOString(),
440
+ error: 'Network error',
441
+ });
442
+ });
443
+ }
444
+ return self.originalXHRSend.apply(this, [body]);
445
+ };
446
+ }
447
+ stop() {
448
+ if (!this.isCapturing)
449
+ return;
450
+ this.isCapturing = false;
451
+ if (this.originalFetch) {
452
+ window.fetch = this.originalFetch;
453
+ }
454
+ if (this.originalXHROpen) {
455
+ XMLHttpRequest.prototype.open = this.originalXHROpen;
456
+ }
457
+ if (this.originalXHRSend) {
458
+ XMLHttpRequest.prototype.send = this.originalXHRSend;
459
+ }
460
+ }
461
+ addEntry(entry) {
462
+ // Filter out CheckFlow API calls
463
+ if (entry.url.includes('checkflow') || entry.url.includes('/api/v1/capture')) {
464
+ return;
465
+ }
466
+ this.entries.push(entry);
467
+ if (this.entries.length > this.maxEntries) {
468
+ this.entries.shift();
469
+ }
470
+ }
471
+ getEntries() {
472
+ return [...this.entries];
473
+ }
474
+ clear() {
475
+ this.entries = [];
476
+ }
477
+ }
478
+ // ==================== Performance Capture ====================
479
+ function capturePerformance() {
480
+ const metrics = {};
481
+ try {
482
+ // Navigation timing
483
+ const navTiming = performance.getEntriesByType('navigation')[0];
484
+ if (navTiming) {
485
+ metrics.loadTime = Math.round(navTiming.loadEventEnd - navTiming.fetchStart);
486
+ metrics.domContentLoaded = Math.round(navTiming.domContentLoadedEventEnd - navTiming.fetchStart);
487
+ }
488
+ // Paint timing
489
+ const paintEntries = performance.getEntriesByType('paint');
490
+ const fcp = paintEntries.find((e) => e.name === 'first-contentful-paint');
491
+ if (fcp) {
492
+ metrics.firstContentfulPaint = Math.round(fcp.startTime);
493
+ }
494
+ // LCP (if available via PerformanceObserver)
495
+ const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
496
+ if (lcpEntries.length > 0) {
497
+ metrics.largestContentfulPaint = Math.round(lcpEntries[lcpEntries.length - 1].startTime);
498
+ }
499
+ // Memory (Chrome only)
500
+ if (performance.memory) {
501
+ metrics.memoryUsage = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024);
502
+ }
503
+ // CLS (approximation from layout shift entries)
504
+ const layoutShiftEntries = performance.getEntriesByType('layout-shift');
505
+ if (layoutShiftEntries.length > 0) {
506
+ metrics.cumulativeLayoutShift = layoutShiftEntries.reduce((sum, entry) => sum + (entry.hadRecentInput ? 0 : entry.value), 0);
507
+ }
508
+ }
509
+ catch (e) {
510
+ // Performance API not fully available
511
+ }
512
+ return metrics;
513
+ }
514
+ // ==================== Page Context ====================
515
+ function capturePageContext() {
516
+ const ua = navigator.userAgent;
517
+ // Parse browser info
518
+ let browserName = 'Unknown';
519
+ let browserVersion = '';
520
+ if (ua.includes('Firefox/')) {
521
+ browserName = 'Firefox';
522
+ browserVersion = ua.match(/Firefox\/(\d+\.\d+)/)?.[1] || '';
523
+ }
524
+ else if (ua.includes('Edg/')) {
525
+ browserName = 'Edge';
526
+ browserVersion = ua.match(/Edg\/(\d+\.\d+)/)?.[1] || '';
527
+ }
528
+ else if (ua.includes('Chrome/')) {
529
+ browserName = 'Chrome';
530
+ browserVersion = ua.match(/Chrome\/(\d+\.\d+)/)?.[1] || '';
531
+ }
532
+ else if (ua.includes('Safari/') && !ua.includes('Chrome')) {
533
+ browserName = 'Safari';
534
+ browserVersion = ua.match(/Version\/(\d+\.\d+)/)?.[1] || '';
535
+ }
536
+ // Parse OS info
537
+ let osName = 'Unknown';
538
+ let osVersion = '';
539
+ if (ua.includes('Windows NT')) {
540
+ osName = 'Windows';
541
+ const ntVersion = ua.match(/Windows NT (\d+\.\d+)/)?.[1];
542
+ if (ntVersion === '10.0')
543
+ osVersion = '10/11';
544
+ else if (ntVersion === '6.3')
545
+ osVersion = '8.1';
546
+ else if (ntVersion === '6.2')
547
+ osVersion = '8';
548
+ else if (ntVersion === '6.1')
549
+ osVersion = '7';
550
+ }
551
+ else if (ua.includes('Mac OS X')) {
552
+ osName = 'macOS';
553
+ osVersion = ua.match(/Mac OS X (\d+[._]\d+)/)?.[1]?.replace('_', '.') || '';
554
+ }
555
+ else if (ua.includes('Linux')) {
556
+ osName = 'Linux';
557
+ }
558
+ else if (ua.includes('Android')) {
559
+ osName = 'Android';
560
+ osVersion = ua.match(/Android (\d+\.\d+)/)?.[1] || '';
561
+ }
562
+ else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) {
563
+ osName = 'iOS';
564
+ osVersion = ua.match(/OS (\d+_\d+)/)?.[1]?.replace('_', '.') || '';
565
+ }
566
+ // Detect device type
567
+ let deviceType = 'desktop';
568
+ if (/Mobi|Android/i.test(ua)) {
569
+ deviceType = 'mobile';
570
+ }
571
+ else if (/Tablet|iPad/i.test(ua)) {
572
+ deviceType = 'tablet';
573
+ }
574
+ return {
575
+ url: window.location.href,
576
+ title: document.title,
577
+ viewport: {
578
+ width: window.innerWidth,
579
+ height: window.innerHeight,
580
+ },
581
+ userAgent: ua,
582
+ browser: {
583
+ name: browserName,
584
+ version: browserVersion,
585
+ },
586
+ os: {
587
+ name: osName,
588
+ version: osVersion,
589
+ },
590
+ deviceType,
591
+ screenResolution: {
592
+ width: window.screen.width,
593
+ height: window.screen.height,
594
+ },
595
+ pixelRatio: window.devicePixelRatio || 1,
596
+ language: navigator.language,
597
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
598
+ referrer: document.referrer || undefined,
599
+ };
600
+ }
601
+ // ==================== Screenshot Capture ====================
602
+ async function captureScreenshot(options = {}) {
603
+ try {
604
+ // Dynamic import of html2canvas
605
+ const html2canvas = (await import('html2canvas')).default;
606
+ // Hide elements if specified
607
+ const hiddenElements = [];
608
+ if (options.hideElements) {
609
+ options.hideElements.forEach((selector) => {
610
+ document.querySelectorAll(selector).forEach((el) => {
611
+ hiddenElements.push({ element: el, display: el.style.display });
612
+ el.style.display = 'none';
613
+ });
614
+ });
615
+ }
616
+ // Mask elements if specified
617
+ const maskedElements = [];
618
+ if (options.maskElements) {
619
+ options.maskElements.forEach((selector) => {
620
+ document.querySelectorAll(selector).forEach((el) => {
621
+ maskedElements.push({ element: el, innerHTML: el.innerHTML });
622
+ el.innerHTML = '••••••••';
623
+ });
624
+ });
625
+ }
626
+ // Wait for delay if specified
627
+ if (options.delay) {
628
+ await new Promise((resolve) => setTimeout(resolve, options.delay));
629
+ }
630
+ // Capture
631
+ const canvas = await html2canvas(document.body, {
632
+ useCORS: true,
633
+ allowTaint: true,
634
+ logging: false,
635
+ scale: Math.min(window.devicePixelRatio || 1, 2),
636
+ windowWidth: options.fullPage ? document.documentElement.scrollWidth : window.innerWidth,
637
+ windowHeight: options.fullPage ? document.documentElement.scrollHeight : window.innerHeight,
638
+ });
639
+ // Restore hidden elements
640
+ hiddenElements.forEach(({ element, display }) => {
641
+ element.style.display = display;
642
+ });
643
+ // Restore masked elements
644
+ maskedElements.forEach(({ element, innerHTML }) => {
645
+ element.innerHTML = innerHTML;
646
+ });
647
+ // Convert to base64
648
+ const quality = (options.quality || 80) / 100;
649
+ return canvas.toDataURL('image/png', quality);
650
+ }
651
+ catch (error) {
652
+ console.error('[CheckFlow] Screenshot capture failed:', error);
653
+ return null;
654
+ }
655
+ }
656
+ // ==================== Main Capture Class ====================
657
+ class ContextCapture {
658
+ constructor(options = {}) {
659
+ this.isCapturing = false;
660
+ this.consoleCapture = new ConsoleCapture(options.maxConsoleEntries || 100);
661
+ this.networkCapture = new NetworkCapture(options.maxNetworkEntries || 100);
662
+ }
663
+ /**
664
+ * Start capturing console and network logs
665
+ */
666
+ startCapture() {
667
+ if (this.isCapturing)
668
+ return;
669
+ this.isCapturing = true;
670
+ this.consoleCapture.start();
671
+ this.networkCapture.start();
672
+ }
673
+ /**
674
+ * Stop capturing
675
+ */
676
+ stopCapture() {
677
+ if (!this.isCapturing)
678
+ return;
679
+ this.isCapturing = false;
680
+ this.consoleCapture.stop();
681
+ this.networkCapture.stop();
682
+ }
683
+ /**
684
+ * Capture current page context with optional screenshot
685
+ */
686
+ async capture(options = {}) {
687
+ const result = {
688
+ context: capturePageContext(),
689
+ capturedAt: new Date().toISOString(),
690
+ };
691
+ // Capture screenshot
692
+ if (options.includeConsole !== false) {
693
+ const screenshot = await captureScreenshot(options);
694
+ if (screenshot) {
695
+ result.screenshot = screenshot;
696
+ }
697
+ }
698
+ // Include console logs
699
+ if (options.includeConsole !== false) {
700
+ result.consoleLogs = this.consoleCapture.getEntries();
701
+ }
702
+ // Include network logs
703
+ if (options.includeNetwork !== false) {
704
+ result.networkLogs = this.networkCapture.getEntries();
705
+ }
706
+ // Include performance metrics
707
+ if (options.includePerformance !== false) {
708
+ result.performance = capturePerformance();
709
+ }
710
+ return result;
711
+ }
712
+ /**
713
+ * Get current console entries
714
+ */
715
+ getConsoleLogs() {
716
+ return this.consoleCapture.getEntries();
717
+ }
718
+ /**
719
+ * Get current network entries
720
+ */
721
+ getNetworkLogs() {
722
+ return this.networkCapture.getEntries();
723
+ }
724
+ /**
725
+ * Clear all captured data
726
+ */
727
+ clear() {
728
+ this.consoleCapture.clear();
729
+ this.networkCapture.clear();
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Error Capture Module
735
+ * Automatic error capture and reporting
736
+ */
737
+ class ErrorCapture {
738
+ constructor(handler) {
739
+ this.isCapturing = false;
740
+ this.capturedErrors = [];
741
+ this.maxErrors = 50;
742
+ this.handler = handler;
743
+ }
744
+ /**
745
+ * Start capturing errors
746
+ */
747
+ start() {
748
+ if (this.isCapturing)
749
+ return;
750
+ this.isCapturing = true;
751
+ // Capture window.onerror
752
+ this.originalOnError = window.onerror;
753
+ window.onerror = (message, filename, lineno, colno, error) => {
754
+ this.captureError({
755
+ message: typeof message === 'string' ? message : message.type,
756
+ stack: error?.stack,
757
+ type: error?.name || 'Error',
758
+ filename,
759
+ lineno,
760
+ colno,
761
+ });
762
+ // Call original handler
763
+ if (this.originalOnError) {
764
+ return this.originalOnError(message, filename, lineno, colno, error);
765
+ }
766
+ return false;
767
+ };
768
+ // Capture unhandled promise rejections
769
+ this.originalOnUnhandledRejection = window.onunhandledrejection;
770
+ window.onunhandledrejection = (event) => {
771
+ const reason = event.reason;
772
+ this.captureError({
773
+ message: reason?.message || String(reason),
774
+ stack: reason?.stack,
775
+ type: 'UnhandledPromiseRejection',
776
+ });
777
+ // Call original handler
778
+ if (this.originalOnUnhandledRejection) {
779
+ return this.originalOnUnhandledRejection(event);
780
+ }
781
+ };
782
+ }
783
+ /**
784
+ * Stop capturing errors
785
+ */
786
+ stop() {
787
+ if (!this.isCapturing)
788
+ return;
789
+ this.isCapturing = false;
790
+ window.onerror = this.originalOnError || null;
791
+ window.onunhandledrejection = this.originalOnUnhandledRejection || null;
792
+ }
793
+ /**
794
+ * Set error handler
795
+ */
796
+ setHandler(handler) {
797
+ this.handler = handler;
798
+ }
799
+ /**
800
+ * Manually capture an error
801
+ */
802
+ captureError(error) {
803
+ const errorInfo = {
804
+ message: error.message || 'Unknown error',
805
+ stack: error.stack,
806
+ type: error.type || 'Error',
807
+ filename: error.filename,
808
+ lineno: error.lineno,
809
+ colno: error.colno,
810
+ timestamp: new Date().toISOString(),
811
+ context: capturePageContext(),
812
+ };
813
+ // Store error
814
+ this.capturedErrors.push(errorInfo);
815
+ if (this.capturedErrors.length > this.maxErrors) {
816
+ this.capturedErrors.shift();
817
+ }
818
+ // Call handler
819
+ if (this.handler) {
820
+ try {
821
+ this.handler(errorInfo);
822
+ }
823
+ catch (e) {
824
+ console.error('[CheckFlow] Error handler failed:', e);
825
+ }
826
+ }
827
+ }
828
+ /**
829
+ * Capture an exception (try/catch style)
830
+ */
831
+ captureException(error, context) {
832
+ this.captureError({
833
+ message: error.message,
834
+ stack: error.stack,
835
+ type: error.name,
836
+ context: context ? { ...capturePageContext(), ...context } : undefined,
837
+ });
838
+ }
839
+ /**
840
+ * Get captured errors
841
+ */
842
+ getErrors() {
843
+ return [...this.capturedErrors];
844
+ }
845
+ /**
846
+ * Clear captured errors
847
+ */
848
+ clear() {
849
+ this.capturedErrors = [];
850
+ }
851
+ }
852
+ // Note: The actual React ErrorBoundary component is in react/ErrorBoundary.tsx
853
+ // This file provides the core error capture logic that can be used by any framework
854
+
855
+ /**
856
+ * CheckFlow SDK Types
857
+ */
858
+ const DEFAULT_TRANSLATIONS = {
859
+ feedbackButton: 'Feedback',
860
+ titleLabel: 'Title',
861
+ titlePlaceholder: 'Brief summary of the issue or suggestion',
862
+ descriptionLabel: 'Description',
863
+ descriptionPlaceholder: 'Provide more details...',
864
+ typeLabel: 'Type',
865
+ priorityLabel: 'Priority',
866
+ typeBug: 'Bug',
867
+ typeFeature: 'Feature Request',
868
+ typeImprovement: 'Improvement',
869
+ typeQuestion: 'Question',
870
+ typeOther: 'Other',
871
+ priorityLow: 'Low',
872
+ priorityMedium: 'Medium',
873
+ priorityHigh: 'High',
874
+ priorityCritical: 'Critical',
875
+ submitButton: 'Submit Feedback',
876
+ cancelButton: 'Cancel',
877
+ captureButton: 'Capture Screenshot',
878
+ retakeButton: 'Retake',
879
+ submitting: 'Submitting...',
880
+ submitSuccess: 'Thank you for your feedback!',
881
+ submitError: 'Failed to submit. Please try again.',
882
+ captureSuccess: 'Screenshot captured',
883
+ captureError: 'Failed to capture screenshot',
884
+ screenshotLabel: 'Screenshot',
885
+ includeScreenshot: 'Include screenshot',
886
+ annotateScreenshot: 'Click to annotate',
887
+ includeConsole: 'Include console logs',
888
+ includeNetwork: 'Include network logs',
889
+ };
890
+ const TRANSLATIONS = {
891
+ en: DEFAULT_TRANSLATIONS,
892
+ fr: {
893
+ feedbackButton: 'Feedback',
894
+ titleLabel: 'Titre',
895
+ titlePlaceholder: 'Résumé bref du problème ou de la suggestion',
896
+ descriptionLabel: 'Description',
897
+ descriptionPlaceholder: 'Fournir plus de détails...',
898
+ typeLabel: 'Type',
899
+ priorityLabel: 'Priorité',
900
+ typeBug: 'Bug',
901
+ typeFeature: 'Demande de fonctionnalité',
902
+ typeImprovement: 'Amélioration',
903
+ typeQuestion: 'Question',
904
+ typeOther: 'Autre',
905
+ priorityLow: 'Faible',
906
+ priorityMedium: 'Moyenne',
907
+ priorityHigh: 'Haute',
908
+ priorityCritical: 'Critique',
909
+ submitButton: 'Envoyer',
910
+ cancelButton: 'Annuler',
911
+ captureButton: 'Capturer l\'écran',
912
+ retakeButton: 'Reprendre',
913
+ submitting: 'Envoi en cours...',
914
+ submitSuccess: 'Merci pour votre feedback!',
915
+ submitError: 'Échec de l\'envoi. Veuillez réessayer.',
916
+ captureSuccess: 'Capture d\'écran effectuée',
917
+ captureError: 'Échec de la capture d\'écran',
918
+ screenshotLabel: 'Capture d\'écran',
919
+ includeScreenshot: 'Inclure une capture d\'écran',
920
+ annotateScreenshot: 'Cliquez pour annoter',
921
+ includeConsole: 'Inclure les logs console',
922
+ includeNetwork: 'Inclure les logs réseau',
923
+ },
924
+ };
925
+
926
+ /**
927
+ * CheckFlow Annotation System Types
928
+ * Figma-like annotation tools for feedback screenshots
929
+ */
930
+ const DEFAULT_STYLE = {
931
+ strokeColor: '#FF3B30',
932
+ strokeWidth: 3,
933
+ fillColor: 'transparent',
934
+ opacity: 1,
935
+ fontSize: 16,
936
+ fontFamily: 'system-ui, -apple-system, sans-serif',
937
+ };
938
+ const HIGHLIGHT_STYLE = {
939
+ strokeColor: 'transparent',
940
+ fillColor: '#FFEB3B',
941
+ opacity: 0.4,
942
+ };
943
+ const BLUR_STYLE = {
944
+ strokeColor: '#666666',
945
+ fillColor: '#666666',
946
+ opacity: 0.8,
947
+ };
948
+ const COLOR_PALETTE = [
949
+ '#FF3B30', // Red
950
+ '#FF9500', // Orange
951
+ '#FFCC00', // Yellow
952
+ '#34C759', // Green
953
+ '#007AFF', // Blue
954
+ '#5856D6', // Purple
955
+ '#AF52DE', // Pink
956
+ '#000000', // Black
957
+ '#FFFFFF', // White
958
+ ];
959
+ const STROKE_WIDTHS = [1, 2, 3, 5, 8];
960
+
961
+ /**
962
+ * CheckFlow Annotation Toolbar
963
+ * Figma-like floating toolbar for annotation tools
964
+ */
965
+ const TOOL_ICONS = {
966
+ select: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="M13 13l6 6"/></svg>`,
967
+ rectangle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>`,
968
+ ellipse: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="12" rx="9" ry="6"/></svg>`,
969
+ arrow: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>`,
970
+ line: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="19" x2="19" y2="5"/></svg>`,
971
+ highlight: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>`,
972
+ blur: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 13c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0 4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0-8c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm-3 .5c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zM6 5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm15 5.5c.28 0 .5-.22.5-.5s-.22-.5-.5-.5-.5.22-.5.5.22.5.5.5zM14 7c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm0-3.5c.28 0 .5-.22.5-.5s-.22-.5-.5-.5-.5.22-.5.5.22.5.5.5zm-11 10c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zm7 7c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zm0-17c.28 0 .5-.22.5-.5s-.22-.5-.5-.5-.5.22-.5.5.22.5.5.5zM10 7c.55 0 1-.45 1-1s-.45-1-1-1-1 .45-1 1 .45 1 1 1zm0 5.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm8 .5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0 4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0-8c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0-4c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm3 8.5c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zM14 17c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm0 3.5c-.28 0-.5.22-.5.5s.22.5.5.5.5-.22.5-.5-.22-.5-.5-.5zm-4-12c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0 8.5c-.55 0-1 .45-1 1s.45 1 1 1 1-.45 1-1-.45-1-1-1zm4-4.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-4c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5z"/></svg>`,
973
+ text: `<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5 4v3h5.5v12h3V7H19V4z"/></svg>`,
974
+ freehand: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/><circle cx="11" cy="11" r="2"/></svg>`,
975
+ };
976
+ const TOOL_LABELS = {
977
+ select: 'Sélection',
978
+ rectangle: 'Rectangle',
979
+ ellipse: 'Ellipse',
980
+ arrow: 'Flèche',
981
+ line: 'Ligne',
982
+ highlight: 'Surbrillance',
983
+ blur: 'Floutage',
984
+ text: 'Texte',
985
+ freehand: 'Dessin libre',
986
+ };
987
+ class AnnotationToolbar {
988
+ constructor(config) {
989
+ this.colorPicker = null;
990
+ this.strokePicker = null;
991
+ this.config = config;
992
+ this.element = this.createToolbar();
993
+ }
994
+ getElement() {
995
+ return this.element;
996
+ }
997
+ setActiveTool(tool) {
998
+ this.config.activeTool = tool;
999
+ this.updateActiveState();
1000
+ }
1001
+ setStyle(style) {
1002
+ this.config.style = style;
1003
+ this.updateStyleDisplay();
1004
+ }
1005
+ createToolbar() {
1006
+ const toolbar = document.createElement('div');
1007
+ toolbar.className = 'cf-toolbar';
1008
+ // Tools section
1009
+ const toolsSection = document.createElement('div');
1010
+ toolsSection.className = 'cf-toolbar-section cf-toolbar-tools';
1011
+ for (const tool of this.config.tools) {
1012
+ const button = this.createToolButton(tool);
1013
+ toolsSection.appendChild(button);
1014
+ }
1015
+ // Separator
1016
+ const separator1 = document.createElement('div');
1017
+ separator1.className = 'cf-toolbar-separator';
1018
+ // Style section
1019
+ const styleSection = document.createElement('div');
1020
+ styleSection.className = 'cf-toolbar-section cf-toolbar-style';
1021
+ // Color picker button
1022
+ const colorBtn = document.createElement('button');
1023
+ colorBtn.className = 'cf-toolbar-btn cf-color-btn';
1024
+ colorBtn.title = 'Couleur';
1025
+ colorBtn.innerHTML = `<span class="cf-color-preview" style="background: ${this.config.style.strokeColor}"></span>`;
1026
+ colorBtn.addEventListener('click', () => this.toggleColorPicker());
1027
+ styleSection.appendChild(colorBtn);
1028
+ // Stroke width button
1029
+ const strokeBtn = document.createElement('button');
1030
+ strokeBtn.className = 'cf-toolbar-btn cf-stroke-btn';
1031
+ strokeBtn.title = 'Épaisseur';
1032
+ strokeBtn.innerHTML = `<span class="cf-stroke-preview">${this.config.style.strokeWidth}px</span>`;
1033
+ strokeBtn.addEventListener('click', () => this.toggleStrokePicker());
1034
+ styleSection.appendChild(strokeBtn);
1035
+ // Separator
1036
+ const separator2 = document.createElement('div');
1037
+ separator2.className = 'cf-toolbar-separator';
1038
+ // Actions section
1039
+ const actionsSection = document.createElement('div');
1040
+ actionsSection.className = 'cf-toolbar-section cf-toolbar-actions';
1041
+ // Undo button
1042
+ const undoBtn = document.createElement('button');
1043
+ undoBtn.className = 'cf-toolbar-btn';
1044
+ undoBtn.title = 'Annuler (Ctrl+Z)';
1045
+ undoBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 00-9-9 9 9 0 00-6 2.3L3 13"/></svg>`;
1046
+ undoBtn.addEventListener('click', () => this.config.onUndo());
1047
+ actionsSection.appendChild(undoBtn);
1048
+ // Clear button
1049
+ const clearBtn = document.createElement('button');
1050
+ clearBtn.className = 'cf-toolbar-btn';
1051
+ clearBtn.title = 'Tout effacer';
1052
+ clearBtn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>`;
1053
+ clearBtn.addEventListener('click', () => this.config.onClear());
1054
+ actionsSection.appendChild(clearBtn);
1055
+ // Assemble toolbar
1056
+ toolbar.appendChild(toolsSection);
1057
+ toolbar.appendChild(separator1);
1058
+ toolbar.appendChild(styleSection);
1059
+ toolbar.appendChild(separator2);
1060
+ toolbar.appendChild(actionsSection);
1061
+ return toolbar;
1062
+ }
1063
+ createToolButton(tool) {
1064
+ const button = document.createElement('button');
1065
+ button.className = `cf-toolbar-btn cf-tool-btn ${tool === this.config.activeTool ? 'active' : ''}`;
1066
+ button.dataset.tool = tool;
1067
+ button.title = TOOL_LABELS[tool];
1068
+ button.innerHTML = TOOL_ICONS[tool];
1069
+ button.addEventListener('click', () => {
1070
+ this.config.onToolChange(tool);
1071
+ this.setActiveTool(tool);
1072
+ });
1073
+ return button;
1074
+ }
1075
+ updateActiveState() {
1076
+ const buttons = this.element.querySelectorAll('.cf-tool-btn');
1077
+ buttons.forEach((btn) => {
1078
+ const button = btn;
1079
+ button.classList.toggle('active', button.dataset.tool === this.config.activeTool);
1080
+ });
1081
+ }
1082
+ updateStyleDisplay() {
1083
+ const colorPreview = this.element.querySelector('.cf-color-preview');
1084
+ if (colorPreview) {
1085
+ colorPreview.style.background = this.config.style.strokeColor;
1086
+ }
1087
+ const strokePreview = this.element.querySelector('.cf-stroke-preview');
1088
+ if (strokePreview) {
1089
+ strokePreview.textContent = `${this.config.style.strokeWidth}px`;
1090
+ }
1091
+ }
1092
+ toggleColorPicker() {
1093
+ if (this.colorPicker) {
1094
+ this.colorPicker.remove();
1095
+ this.colorPicker = null;
1096
+ return;
1097
+ }
1098
+ // Close stroke picker if open
1099
+ if (this.strokePicker) {
1100
+ this.strokePicker.remove();
1101
+ this.strokePicker = null;
1102
+ }
1103
+ this.colorPicker = document.createElement('div');
1104
+ this.colorPicker.className = 'cf-picker cf-color-picker';
1105
+ for (const color of COLOR_PALETTE) {
1106
+ const swatch = document.createElement('button');
1107
+ swatch.className = `cf-color-swatch ${color === this.config.style.strokeColor ? 'active' : ''}`;
1108
+ swatch.style.background = color;
1109
+ swatch.addEventListener('click', () => {
1110
+ this.config.onStyleChange({ strokeColor: color });
1111
+ this.setStyle({ ...this.config.style, strokeColor: color });
1112
+ this.colorPicker?.remove();
1113
+ this.colorPicker = null;
1114
+ });
1115
+ this.colorPicker.appendChild(swatch);
1116
+ }
1117
+ const colorBtn = this.element.querySelector('.cf-color-btn');
1118
+ colorBtn?.appendChild(this.colorPicker);
1119
+ }
1120
+ toggleStrokePicker() {
1121
+ if (this.strokePicker) {
1122
+ this.strokePicker.remove();
1123
+ this.strokePicker = null;
1124
+ return;
1125
+ }
1126
+ // Close color picker if open
1127
+ if (this.colorPicker) {
1128
+ this.colorPicker.remove();
1129
+ this.colorPicker = null;
1130
+ }
1131
+ this.strokePicker = document.createElement('div');
1132
+ this.strokePicker.className = 'cf-picker cf-stroke-picker';
1133
+ for (const width of STROKE_WIDTHS) {
1134
+ const option = document.createElement('button');
1135
+ option.className = `cf-stroke-option ${width === this.config.style.strokeWidth ? 'active' : ''}`;
1136
+ option.innerHTML = `<span style="height: ${width}px"></span> ${width}px`;
1137
+ option.addEventListener('click', () => {
1138
+ this.config.onStyleChange({ strokeWidth: width });
1139
+ this.setStyle({ ...this.config.style, strokeWidth: width });
1140
+ this.strokePicker?.remove();
1141
+ this.strokePicker = null;
1142
+ });
1143
+ this.strokePicker.appendChild(option);
1144
+ }
1145
+ const strokeBtn = this.element.querySelector('.cf-stroke-btn');
1146
+ strokeBtn?.appendChild(this.strokePicker);
1147
+ }
1148
+ }
1149
+
1150
+ /**
1151
+ * CheckFlow Annotation Styles
1152
+ * CSS-in-JS styles for the annotation editor
1153
+ */
1154
+ const STYLES = `
1155
+ /* Annotation Overlay */
1156
+ .cf-annotation-overlay {
1157
+ position: fixed;
1158
+ top: 0;
1159
+ left: 0;
1160
+ right: 0;
1161
+ bottom: 0;
1162
+ z-index: 999999;
1163
+ background: rgba(0, 0, 0, 0.85);
1164
+ display: flex;
1165
+ align-items: center;
1166
+ justify-content: center;
1167
+ animation: cf-fade-in 0.2s ease-out;
1168
+ }
1169
+
1170
+ @keyframes cf-fade-in {
1171
+ from { opacity: 0; }
1172
+ to { opacity: 1; }
1173
+ }
1174
+
1175
+ /* Editor Wrapper */
1176
+ .cf-annotation-wrapper {
1177
+ display: flex;
1178
+ flex-direction: column;
1179
+ max-width: 95vw;
1180
+ max-height: 95vh;
1181
+ background: #1a1a1a;
1182
+ border-radius: 12px;
1183
+ overflow: hidden;
1184
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
1185
+ }
1186
+
1187
+ /* Canvas Container */
1188
+ .cf-annotation-canvas-container {
1189
+ flex: 1;
1190
+ display: flex;
1191
+ align-items: center;
1192
+ justify-content: center;
1193
+ overflow: auto;
1194
+ padding: 16px;
1195
+ background: #0d0d0d;
1196
+ }
1197
+
1198
+ .cf-annotation-canvas {
1199
+ max-width: 100%;
1200
+ max-height: calc(95vh - 140px);
1201
+ border-radius: 4px;
1202
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
1203
+ cursor: crosshair;
1204
+ }
1205
+
1206
+ /* Toolbar */
1207
+ .cf-toolbar {
1208
+ display: flex;
1209
+ align-items: center;
1210
+ gap: 8px;
1211
+ padding: 12px 16px;
1212
+ background: #2a2a2a;
1213
+ border-bottom: 1px solid #3a3a3a;
1214
+ }
1215
+
1216
+ .cf-toolbar-section {
1217
+ display: flex;
1218
+ align-items: center;
1219
+ gap: 4px;
1220
+ }
1221
+
1222
+ .cf-toolbar-separator {
1223
+ width: 1px;
1224
+ height: 24px;
1225
+ background: #4a4a4a;
1226
+ margin: 0 8px;
1227
+ }
1228
+
1229
+ .cf-toolbar-btn {
1230
+ display: flex;
1231
+ align-items: center;
1232
+ justify-content: center;
1233
+ width: 36px;
1234
+ height: 36px;
1235
+ padding: 0;
1236
+ border: none;
1237
+ border-radius: 8px;
1238
+ background: transparent;
1239
+ color: #999;
1240
+ cursor: pointer;
1241
+ transition: all 0.15s ease;
1242
+ }
1243
+
1244
+ .cf-toolbar-btn:hover {
1245
+ background: #3a3a3a;
1246
+ color: #fff;
1247
+ }
1248
+
1249
+ .cf-toolbar-btn.active {
1250
+ background: #007AFF;
1251
+ color: #fff;
1252
+ }
1253
+
1254
+ .cf-toolbar-btn svg {
1255
+ width: 20px;
1256
+ height: 20px;
1257
+ }
1258
+
1259
+ /* Color Button */
1260
+ .cf-color-btn {
1261
+ position: relative;
1262
+ }
1263
+
1264
+ .cf-color-preview {
1265
+ width: 20px;
1266
+ height: 20px;
1267
+ border-radius: 50%;
1268
+ border: 2px solid #fff;
1269
+ box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
1270
+ }
1271
+
1272
+ /* Stroke Button */
1273
+ .cf-stroke-btn {
1274
+ width: auto;
1275
+ padding: 0 12px;
1276
+ }
1277
+
1278
+ .cf-stroke-preview {
1279
+ font-size: 12px;
1280
+ font-weight: 500;
1281
+ color: inherit;
1282
+ }
1283
+
1284
+ /* Pickers */
1285
+ .cf-picker {
1286
+ position: absolute;
1287
+ top: 100%;
1288
+ left: 50%;
1289
+ transform: translateX(-50%);
1290
+ margin-top: 8px;
1291
+ padding: 8px;
1292
+ background: #2a2a2a;
1293
+ border-radius: 8px;
1294
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1295
+ z-index: 10;
1296
+ }
1297
+
1298
+ .cf-color-picker {
1299
+ display: grid;
1300
+ grid-template-columns: repeat(5, 1fr);
1301
+ gap: 4px;
1302
+ width: 140px;
1303
+ }
1304
+
1305
+ .cf-color-swatch {
1306
+ width: 24px;
1307
+ height: 24px;
1308
+ border-radius: 50%;
1309
+ border: 2px solid transparent;
1310
+ cursor: pointer;
1311
+ transition: transform 0.15s ease;
1312
+ }
1313
+
1314
+ .cf-color-swatch:hover {
1315
+ transform: scale(1.15);
1316
+ }
1317
+
1318
+ .cf-color-swatch.active {
1319
+ border-color: #fff;
1320
+ }
1321
+
1322
+ .cf-stroke-picker {
1323
+ display: flex;
1324
+ flex-direction: column;
1325
+ gap: 4px;
1326
+ min-width: 80px;
1327
+ }
1328
+
1329
+ .cf-stroke-option {
1330
+ display: flex;
1331
+ align-items: center;
1332
+ gap: 8px;
1333
+ padding: 6px 10px;
1334
+ border: none;
1335
+ border-radius: 4px;
1336
+ background: transparent;
1337
+ color: #999;
1338
+ font-size: 12px;
1339
+ cursor: pointer;
1340
+ transition: all 0.15s ease;
1341
+ }
1342
+
1343
+ .cf-stroke-option:hover {
1344
+ background: #3a3a3a;
1345
+ color: #fff;
1346
+ }
1347
+
1348
+ .cf-stroke-option.active {
1349
+ background: #007AFF;
1350
+ color: #fff;
1351
+ }
1352
+
1353
+ .cf-stroke-option span {
1354
+ width: 24px;
1355
+ background: currentColor;
1356
+ border-radius: 2px;
1357
+ }
1358
+
1359
+ /* Action Buttons */
1360
+ .cf-annotation-actions {
1361
+ display: flex;
1362
+ justify-content: flex-end;
1363
+ gap: 12px;
1364
+ padding: 12px 16px;
1365
+ background: #2a2a2a;
1366
+ border-top: 1px solid #3a3a3a;
1367
+ }
1368
+
1369
+ .cf-btn {
1370
+ display: flex;
1371
+ align-items: center;
1372
+ justify-content: center;
1373
+ padding: 10px 20px;
1374
+ border: none;
1375
+ border-radius: 8px;
1376
+ font-size: 14px;
1377
+ font-weight: 500;
1378
+ cursor: pointer;
1379
+ transition: all 0.15s ease;
1380
+ }
1381
+
1382
+ .cf-btn-primary {
1383
+ background: #007AFF;
1384
+ color: #fff;
1385
+ }
1386
+
1387
+ .cf-btn-primary:hover {
1388
+ background: #0066DD;
1389
+ }
1390
+
1391
+ .cf-btn-secondary {
1392
+ background: #3a3a3a;
1393
+ color: #fff;
1394
+ }
1395
+
1396
+ .cf-btn-secondary:hover {
1397
+ background: #4a4a4a;
1398
+ }
1399
+
1400
+ /* Text Input */
1401
+ .cf-annotation-text-input {
1402
+ position: fixed;
1403
+ z-index: 1000000;
1404
+ padding: 4px 8px;
1405
+ border: 2px solid #007AFF;
1406
+ border-radius: 4px;
1407
+ background: rgba(255, 255, 255, 0.95);
1408
+ font-size: 16px;
1409
+ font-family: system-ui, -apple-system, sans-serif;
1410
+ outline: none;
1411
+ min-width: 150px;
1412
+ }
1413
+
1414
+ /* Tool-specific cursors */
1415
+ .cf-annotation-canvas[data-tool="select"] { cursor: default; }
1416
+ .cf-annotation-canvas[data-tool="rectangle"] { cursor: crosshair; }
1417
+ .cf-annotation-canvas[data-tool="ellipse"] { cursor: crosshair; }
1418
+ .cf-annotation-canvas[data-tool="arrow"] { cursor: crosshair; }
1419
+ .cf-annotation-canvas[data-tool="line"] { cursor: crosshair; }
1420
+ .cf-annotation-canvas[data-tool="highlight"] { cursor: crosshair; }
1421
+ .cf-annotation-canvas[data-tool="blur"] { cursor: crosshair; }
1422
+ .cf-annotation-canvas[data-tool="text"] { cursor: text; }
1423
+ .cf-annotation-canvas[data-tool="freehand"] { cursor: crosshair; }
1424
+
1425
+ /* Responsive */
1426
+ @media (max-width: 768px) {
1427
+ .cf-toolbar {
1428
+ flex-wrap: wrap;
1429
+ justify-content: center;
1430
+ }
1431
+
1432
+ .cf-toolbar-separator {
1433
+ display: none;
1434
+ }
1435
+
1436
+ .cf-annotation-actions {
1437
+ justify-content: stretch;
1438
+ }
1439
+
1440
+ .cf-annotation-actions .cf-btn {
1441
+ flex: 1;
1442
+ }
1443
+ }
1444
+ `;
1445
+ let stylesInjected = false;
1446
+ function injectAnnotationStyles() {
1447
+ if (stylesInjected)
1448
+ return;
1449
+ const styleElement = document.createElement('style');
1450
+ styleElement.id = 'cf-annotation-styles';
1451
+ styleElement.textContent = STYLES;
1452
+ document.head.appendChild(styleElement);
1453
+ stylesInjected = true;
1454
+ }
1455
+ function removeAnnotationStyles() {
1456
+ const styleElement = document.getElementById('cf-annotation-styles');
1457
+ if (styleElement) {
1458
+ styleElement.remove();
1459
+ stylesInjected = false;
1460
+ }
1461
+ }
1462
+
1463
+ /**
1464
+ * CheckFlow Annotation Editor
1465
+ * Figma-like canvas editor for annotating screenshots
1466
+ */
1467
+ class AnnotationEditor {
1468
+ constructor(config) {
1469
+ this.container = null;
1470
+ this.canvas = null;
1471
+ this.ctx = null;
1472
+ this.toolbar = null;
1473
+ this.annotations = [];
1474
+ this.backgroundImage = null;
1475
+ this.state = {
1476
+ activeTool: 'rectangle',
1477
+ style: { ...DEFAULT_STYLE },
1478
+ isDrawing: false,
1479
+ currentAnnotation: null,
1480
+ };
1481
+ this.startPoint = null;
1482
+ this.freehandPoints = [];
1483
+ this.textInput = null;
1484
+ this.config = {
1485
+ ...config,
1486
+ tools: config.tools || ['select', 'rectangle', 'arrow', 'highlight', 'blur', 'text', 'freehand'],
1487
+ defaultStyle: config.defaultStyle || DEFAULT_STYLE,
1488
+ };
1489
+ this.state.style = { ...this.config.defaultStyle };
1490
+ }
1491
+ /**
1492
+ * Open the annotation editor with a screenshot
1493
+ */
1494
+ async open(screenshotDataUrl) {
1495
+ injectAnnotationStyles();
1496
+ // Load the background image
1497
+ this.backgroundImage = await this.loadImage(screenshotDataUrl);
1498
+ // Create the editor UI
1499
+ this.createEditorUI();
1500
+ // Setup event listeners
1501
+ this.setupEventListeners();
1502
+ // Initial render
1503
+ this.render();
1504
+ }
1505
+ /**
1506
+ * Close the editor
1507
+ */
1508
+ close() {
1509
+ if (this.container) {
1510
+ this.container.remove();
1511
+ this.container = null;
1512
+ }
1513
+ this.canvas = null;
1514
+ this.ctx = null;
1515
+ this.toolbar = null;
1516
+ this.annotations = [];
1517
+ this.backgroundImage = null;
1518
+ }
1519
+ /**
1520
+ * Get the annotated image as data URL
1521
+ */
1522
+ getAnnotatedImage() {
1523
+ if (!this.canvas || !this.ctx)
1524
+ return '';
1525
+ // Create a temporary canvas to flatten the image
1526
+ const tempCanvas = document.createElement('canvas');
1527
+ tempCanvas.width = this.canvas.width;
1528
+ tempCanvas.height = this.canvas.height;
1529
+ const tempCtx = tempCanvas.getContext('2d');
1530
+ // Draw background
1531
+ if (this.backgroundImage) {
1532
+ tempCtx.drawImage(this.backgroundImage, 0, 0);
1533
+ }
1534
+ // Draw all annotations
1535
+ this.drawAnnotations(tempCtx);
1536
+ return tempCanvas.toDataURL('image/png');
1537
+ }
1538
+ /**
1539
+ * Get annotations data
1540
+ */
1541
+ getAnnotations() {
1542
+ return [...this.annotations];
1543
+ }
1544
+ // Private methods
1545
+ loadImage(src) {
1546
+ return new Promise((resolve, reject) => {
1547
+ const img = new Image();
1548
+ img.onload = () => resolve(img);
1549
+ img.onerror = reject;
1550
+ img.src = src;
1551
+ });
1552
+ }
1553
+ createEditorUI() {
1554
+ // Create overlay container
1555
+ this.container = document.createElement('div');
1556
+ this.container.className = 'cf-annotation-overlay';
1557
+ // Create editor wrapper
1558
+ const wrapper = document.createElement('div');
1559
+ wrapper.className = 'cf-annotation-wrapper';
1560
+ // Create canvas container
1561
+ const canvasContainer = document.createElement('div');
1562
+ canvasContainer.className = 'cf-annotation-canvas-container';
1563
+ // Create canvas
1564
+ this.canvas = document.createElement('canvas');
1565
+ this.canvas.className = 'cf-annotation-canvas';
1566
+ this.canvas.width = this.backgroundImage?.width || 1920;
1567
+ this.canvas.height = this.backgroundImage?.height || 1080;
1568
+ this.ctx = this.canvas.getContext('2d');
1569
+ canvasContainer.appendChild(this.canvas);
1570
+ // Create toolbar
1571
+ this.toolbar = new AnnotationToolbar({
1572
+ tools: this.config.tools,
1573
+ activeTool: this.state.activeTool,
1574
+ style: this.state.style,
1575
+ onToolChange: (tool) => this.setActiveTool(tool),
1576
+ onStyleChange: (style) => this.setStyle(style),
1577
+ onUndo: () => this.undo(),
1578
+ onClear: () => this.clearAll(),
1579
+ onSave: () => this.save(),
1580
+ onCancel: () => this.cancel(),
1581
+ });
1582
+ // Create action buttons
1583
+ const actions = document.createElement('div');
1584
+ actions.className = 'cf-annotation-actions';
1585
+ actions.innerHTML = `
1586
+ <button class="cf-btn cf-btn-secondary" data-action="cancel">Annuler</button>
1587
+ <button class="cf-btn cf-btn-primary" data-action="save">Enregistrer</button>
1588
+ `;
1589
+ // Assemble UI
1590
+ wrapper.appendChild(this.toolbar.getElement());
1591
+ wrapper.appendChild(canvasContainer);
1592
+ wrapper.appendChild(actions);
1593
+ this.container.appendChild(wrapper);
1594
+ document.body.appendChild(this.container);
1595
+ // Bind action buttons
1596
+ actions.querySelector('[data-action="cancel"]')?.addEventListener('click', () => this.cancel());
1597
+ actions.querySelector('[data-action="save"]')?.addEventListener('click', () => this.save());
1598
+ }
1599
+ setupEventListeners() {
1600
+ if (!this.canvas)
1601
+ return;
1602
+ this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
1603
+ this.canvas.addEventListener('mousemove', this.handleMouseMove.bind(this));
1604
+ this.canvas.addEventListener('mouseup', this.handleMouseUp.bind(this));
1605
+ this.canvas.addEventListener('mouseleave', this.handleMouseUp.bind(this));
1606
+ // Touch support
1607
+ this.canvas.addEventListener('touchstart', this.handleTouchStart.bind(this));
1608
+ this.canvas.addEventListener('touchmove', this.handleTouchMove.bind(this));
1609
+ this.canvas.addEventListener('touchend', this.handleTouchEnd.bind(this));
1610
+ // Keyboard shortcuts
1611
+ document.addEventListener('keydown', this.handleKeyDown.bind(this));
1612
+ }
1613
+ handleMouseDown(e) {
1614
+ const point = this.getCanvasPoint(e);
1615
+ this.startDrawing(point);
1616
+ }
1617
+ handleMouseMove(e) {
1618
+ const point = this.getCanvasPoint(e);
1619
+ this.continueDrawing(point);
1620
+ }
1621
+ handleMouseUp(_e) {
1622
+ this.finishDrawing();
1623
+ }
1624
+ handleTouchStart(e) {
1625
+ e.preventDefault();
1626
+ const touch = e.touches[0];
1627
+ const point = this.getCanvasPointFromTouch(touch);
1628
+ this.startDrawing(point);
1629
+ }
1630
+ handleTouchMove(e) {
1631
+ e.preventDefault();
1632
+ const touch = e.touches[0];
1633
+ const point = this.getCanvasPointFromTouch(touch);
1634
+ this.continueDrawing(point);
1635
+ }
1636
+ handleTouchEnd(e) {
1637
+ e.preventDefault();
1638
+ this.finishDrawing();
1639
+ }
1640
+ handleKeyDown(e) {
1641
+ // Undo: Ctrl+Z / Cmd+Z
1642
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
1643
+ e.preventDefault();
1644
+ this.undo();
1645
+ }
1646
+ // Escape: Cancel
1647
+ if (e.key === 'Escape') {
1648
+ if (this.state.isDrawing) {
1649
+ this.state.isDrawing = false;
1650
+ this.state.currentAnnotation = null;
1651
+ this.render();
1652
+ }
1653
+ else {
1654
+ this.cancel();
1655
+ }
1656
+ }
1657
+ // Enter: Save (when not drawing)
1658
+ if (e.key === 'Enter' && !this.state.isDrawing) {
1659
+ this.save();
1660
+ }
1661
+ }
1662
+ getCanvasPoint(e) {
1663
+ const rect = this.canvas.getBoundingClientRect();
1664
+ const scaleX = this.canvas.width / rect.width;
1665
+ const scaleY = this.canvas.height / rect.height;
1666
+ return {
1667
+ x: (e.clientX - rect.left) * scaleX,
1668
+ y: (e.clientY - rect.top) * scaleY,
1669
+ };
1670
+ }
1671
+ getCanvasPointFromTouch(touch) {
1672
+ const rect = this.canvas.getBoundingClientRect();
1673
+ const scaleX = this.canvas.width / rect.width;
1674
+ const scaleY = this.canvas.height / rect.height;
1675
+ return {
1676
+ x: (touch.clientX - rect.left) * scaleX,
1677
+ y: (touch.clientY - rect.top) * scaleY,
1678
+ };
1679
+ }
1680
+ startDrawing(point) {
1681
+ if (this.state.activeTool === 'select')
1682
+ return;
1683
+ this.state.isDrawing = true;
1684
+ this.startPoint = point;
1685
+ if (this.state.activeTool === 'text') {
1686
+ this.showTextInput(point);
1687
+ return;
1688
+ }
1689
+ if (this.state.activeTool === 'freehand') {
1690
+ this.freehandPoints = [point];
1691
+ }
1692
+ }
1693
+ continueDrawing(point) {
1694
+ if (!this.state.isDrawing || !this.startPoint)
1695
+ return;
1696
+ if (this.state.activeTool === 'freehand') {
1697
+ this.freehandPoints.push(point);
1698
+ }
1699
+ // Create preview annotation
1700
+ this.state.currentAnnotation = this.createAnnotation(this.startPoint, point);
1701
+ this.render();
1702
+ }
1703
+ finishDrawing() {
1704
+ if (!this.state.isDrawing || !this.state.currentAnnotation) {
1705
+ this.state.isDrawing = false;
1706
+ return;
1707
+ }
1708
+ // Don't add empty annotations
1709
+ if (this.isValidAnnotation(this.state.currentAnnotation)) {
1710
+ this.annotations.push(this.state.currentAnnotation);
1711
+ }
1712
+ this.state.isDrawing = false;
1713
+ this.state.currentAnnotation = null;
1714
+ this.startPoint = null;
1715
+ this.freehandPoints = [];
1716
+ this.render();
1717
+ }
1718
+ isValidAnnotation(annotation) {
1719
+ switch (annotation.type) {
1720
+ case 'rectangle':
1721
+ case 'ellipse':
1722
+ case 'highlight':
1723
+ case 'blur':
1724
+ const bounds = annotation.bounds;
1725
+ return Math.abs(bounds.width) > 5 && Math.abs(bounds.height) > 5;
1726
+ case 'arrow':
1727
+ case 'line':
1728
+ const start = annotation.start;
1729
+ const end = annotation.end;
1730
+ const dist = Math.sqrt(Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2));
1731
+ return dist > 10;
1732
+ case 'freehand':
1733
+ return annotation.points.length > 2;
1734
+ case 'text':
1735
+ return !!annotation.text?.trim();
1736
+ default:
1737
+ return true;
1738
+ }
1739
+ }
1740
+ createAnnotation(start, end) {
1741
+ const id = this.generateId();
1742
+ const baseStyle = { ...this.state.style };
1743
+ switch (this.state.activeTool) {
1744
+ case 'rectangle':
1745
+ return {
1746
+ id,
1747
+ type: 'rectangle',
1748
+ bounds: this.createBounds(start, end),
1749
+ style: baseStyle,
1750
+ timestamp: Date.now(),
1751
+ };
1752
+ case 'ellipse':
1753
+ return {
1754
+ id,
1755
+ type: 'ellipse',
1756
+ bounds: this.createBounds(start, end),
1757
+ style: baseStyle,
1758
+ timestamp: Date.now(),
1759
+ };
1760
+ case 'arrow':
1761
+ return {
1762
+ id,
1763
+ type: 'arrow',
1764
+ start: { ...start },
1765
+ end: { ...end },
1766
+ style: baseStyle,
1767
+ timestamp: Date.now(),
1768
+ };
1769
+ case 'line':
1770
+ return {
1771
+ id,
1772
+ type: 'line',
1773
+ start: { ...start },
1774
+ end: { ...end },
1775
+ style: baseStyle,
1776
+ timestamp: Date.now(),
1777
+ };
1778
+ case 'highlight':
1779
+ return {
1780
+ id,
1781
+ type: 'highlight',
1782
+ bounds: this.createBounds(start, end),
1783
+ style: { ...baseStyle, ...HIGHLIGHT_STYLE },
1784
+ timestamp: Date.now(),
1785
+ };
1786
+ case 'blur':
1787
+ return {
1788
+ id,
1789
+ type: 'blur',
1790
+ bounds: this.createBounds(start, end),
1791
+ style: { ...baseStyle, ...BLUR_STYLE },
1792
+ blurAmount: 10,
1793
+ timestamp: Date.now(),
1794
+ };
1795
+ case 'freehand':
1796
+ return {
1797
+ id,
1798
+ type: 'freehand',
1799
+ points: [...this.freehandPoints],
1800
+ style: baseStyle,
1801
+ timestamp: Date.now(),
1802
+ };
1803
+ default:
1804
+ return {
1805
+ id,
1806
+ type: 'rectangle',
1807
+ bounds: this.createBounds(start, end),
1808
+ style: baseStyle,
1809
+ timestamp: Date.now(),
1810
+ };
1811
+ }
1812
+ }
1813
+ createBounds(start, end) {
1814
+ return {
1815
+ x: Math.min(start.x, end.x),
1816
+ y: Math.min(start.y, end.y),
1817
+ width: Math.abs(end.x - start.x),
1818
+ height: Math.abs(end.y - start.y),
1819
+ };
1820
+ }
1821
+ showTextInput(point) {
1822
+ if (this.textInput) {
1823
+ this.textInput.remove();
1824
+ }
1825
+ const rect = this.canvas.getBoundingClientRect();
1826
+ const scaleX = rect.width / this.canvas.width;
1827
+ const scaleY = rect.height / this.canvas.height;
1828
+ this.textInput = document.createElement('input');
1829
+ this.textInput.type = 'text';
1830
+ this.textInput.className = 'cf-annotation-text-input';
1831
+ this.textInput.style.left = `${rect.left + point.x * scaleX}px`;
1832
+ this.textInput.style.top = `${rect.top + point.y * scaleY}px`;
1833
+ this.textInput.style.color = this.state.style.strokeColor;
1834
+ this.textInput.style.fontSize = `${(this.state.style.fontSize || 16) * scaleY}px`;
1835
+ this.textInput.placeholder = 'Tapez votre texte...';
1836
+ document.body.appendChild(this.textInput);
1837
+ this.textInput.focus();
1838
+ const handleTextSubmit = () => {
1839
+ if (this.textInput && this.textInput.value.trim()) {
1840
+ const textAnnotation = {
1841
+ id: this.generateId(),
1842
+ type: 'text',
1843
+ position: point,
1844
+ text: this.textInput.value.trim(),
1845
+ style: { ...this.state.style },
1846
+ timestamp: Date.now(),
1847
+ };
1848
+ this.annotations.push(textAnnotation);
1849
+ this.render();
1850
+ }
1851
+ this.textInput?.remove();
1852
+ this.textInput = null;
1853
+ this.state.isDrawing = false;
1854
+ };
1855
+ this.textInput.addEventListener('blur', handleTextSubmit);
1856
+ this.textInput.addEventListener('keydown', (e) => {
1857
+ if (e.key === 'Enter') {
1858
+ handleTextSubmit();
1859
+ }
1860
+ else if (e.key === 'Escape') {
1861
+ this.textInput?.remove();
1862
+ this.textInput = null;
1863
+ this.state.isDrawing = false;
1864
+ }
1865
+ });
1866
+ }
1867
+ generateId() {
1868
+ return `ann_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
1869
+ }
1870
+ setActiveTool(tool) {
1871
+ this.state.activeTool = tool;
1872
+ this.toolbar?.setActiveTool(tool);
1873
+ // Update cursor
1874
+ if (this.canvas) {
1875
+ this.canvas.style.cursor = tool === 'select' ? 'default' : 'crosshair';
1876
+ }
1877
+ }
1878
+ setStyle(style) {
1879
+ this.state.style = { ...this.state.style, ...style };
1880
+ this.toolbar?.setStyle(this.state.style);
1881
+ }
1882
+ undo() {
1883
+ if (this.annotations.length > 0) {
1884
+ this.annotations.pop();
1885
+ this.render();
1886
+ }
1887
+ }
1888
+ clearAll() {
1889
+ this.annotations = [];
1890
+ this.render();
1891
+ }
1892
+ save() {
1893
+ const imageData = this.getAnnotatedImage();
1894
+ this.config.onSave?.(this.annotations, imageData);
1895
+ this.close();
1896
+ }
1897
+ cancel() {
1898
+ this.config.onCancel?.();
1899
+ this.close();
1900
+ }
1901
+ render() {
1902
+ if (!this.ctx || !this.canvas)
1903
+ return;
1904
+ // Clear canvas
1905
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
1906
+ // Draw background image
1907
+ if (this.backgroundImage) {
1908
+ this.ctx.drawImage(this.backgroundImage, 0, 0);
1909
+ }
1910
+ // Draw all saved annotations
1911
+ this.drawAnnotations(this.ctx);
1912
+ // Draw current annotation (preview)
1913
+ if (this.state.currentAnnotation) {
1914
+ this.drawAnnotation(this.ctx, this.state.currentAnnotation);
1915
+ }
1916
+ }
1917
+ drawAnnotations(ctx) {
1918
+ for (const annotation of this.annotations) {
1919
+ this.drawAnnotation(ctx, annotation);
1920
+ }
1921
+ }
1922
+ drawAnnotation(ctx, annotation) {
1923
+ ctx.save();
1924
+ ctx.globalAlpha = annotation.style.opacity;
1925
+ ctx.strokeStyle = annotation.style.strokeColor;
1926
+ ctx.fillStyle = annotation.style.fillColor;
1927
+ ctx.lineWidth = annotation.style.strokeWidth;
1928
+ ctx.lineCap = 'round';
1929
+ ctx.lineJoin = 'round';
1930
+ switch (annotation.type) {
1931
+ case 'rectangle':
1932
+ this.drawRectangle(ctx, annotation.bounds, annotation.style);
1933
+ break;
1934
+ case 'ellipse':
1935
+ this.drawEllipse(ctx, annotation.bounds, annotation.style);
1936
+ break;
1937
+ case 'arrow':
1938
+ this.drawArrow(ctx, annotation.start, annotation.end, annotation.style);
1939
+ break;
1940
+ case 'line':
1941
+ this.drawLine(ctx, annotation.start, annotation.end);
1942
+ break;
1943
+ case 'highlight':
1944
+ this.drawHighlight(ctx, annotation.bounds, annotation.style);
1945
+ break;
1946
+ case 'blur':
1947
+ this.drawBlur(ctx, annotation.bounds, annotation.blurAmount);
1948
+ break;
1949
+ case 'text':
1950
+ this.drawText(ctx, annotation.position, annotation.text, annotation.style);
1951
+ break;
1952
+ case 'freehand':
1953
+ this.drawFreehand(ctx, annotation.points);
1954
+ break;
1955
+ }
1956
+ ctx.restore();
1957
+ }
1958
+ drawRectangle(ctx, bounds, style) {
1959
+ if (style.fillColor !== 'transparent') {
1960
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
1961
+ }
1962
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
1963
+ }
1964
+ drawEllipse(ctx, bounds, style) {
1965
+ const centerX = bounds.x + bounds.width / 2;
1966
+ const centerY = bounds.y + bounds.height / 2;
1967
+ const radiusX = bounds.width / 2;
1968
+ const radiusY = bounds.height / 2;
1969
+ ctx.beginPath();
1970
+ ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
1971
+ if (style.fillColor !== 'transparent') {
1972
+ ctx.fill();
1973
+ }
1974
+ ctx.stroke();
1975
+ }
1976
+ drawArrow(ctx, start, end, style) {
1977
+ const headLength = 15 + style.strokeWidth * 2;
1978
+ const angle = Math.atan2(end.y - start.y, end.x - start.x);
1979
+ // Draw line
1980
+ ctx.beginPath();
1981
+ ctx.moveTo(start.x, start.y);
1982
+ ctx.lineTo(end.x, end.y);
1983
+ ctx.stroke();
1984
+ // Draw arrowhead
1985
+ ctx.beginPath();
1986
+ ctx.moveTo(end.x, end.y);
1987
+ ctx.lineTo(end.x - headLength * Math.cos(angle - Math.PI / 6), end.y - headLength * Math.sin(angle - Math.PI / 6));
1988
+ ctx.lineTo(end.x - headLength * Math.cos(angle + Math.PI / 6), end.y - headLength * Math.sin(angle + Math.PI / 6));
1989
+ ctx.closePath();
1990
+ ctx.fillStyle = style.strokeColor;
1991
+ ctx.fill();
1992
+ }
1993
+ drawLine(ctx, start, end) {
1994
+ ctx.beginPath();
1995
+ ctx.moveTo(start.x, start.y);
1996
+ ctx.lineTo(end.x, end.y);
1997
+ ctx.stroke();
1998
+ }
1999
+ drawHighlight(ctx, bounds, style) {
2000
+ ctx.fillStyle = style.fillColor;
2001
+ ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
2002
+ }
2003
+ drawBlur(ctx, bounds, blurAmount) {
2004
+ // For blur, we pixelate the area
2005
+ const pixelSize = Math.max(blurAmount, 5);
2006
+ // Get image data for the blur region
2007
+ if (this.backgroundImage) {
2008
+ const tempCanvas = document.createElement('canvas');
2009
+ tempCanvas.width = bounds.width;
2010
+ tempCanvas.height = bounds.height;
2011
+ const tempCtx = tempCanvas.getContext('2d');
2012
+ // Draw the region from background
2013
+ tempCtx.drawImage(this.backgroundImage, bounds.x, bounds.y, bounds.width, bounds.height, 0, 0, bounds.width, bounds.height);
2014
+ // Pixelate
2015
+ const w = tempCanvas.width;
2016
+ const h = tempCanvas.height;
2017
+ // Scale down
2018
+ tempCtx.imageSmoothingEnabled = false;
2019
+ tempCtx.drawImage(tempCanvas, 0, 0, w, h, 0, 0, w / pixelSize, h / pixelSize);
2020
+ // Scale back up
2021
+ tempCtx.drawImage(tempCanvas, 0, 0, w / pixelSize, h / pixelSize, 0, 0, w, h);
2022
+ // Draw back to main canvas
2023
+ ctx.drawImage(tempCanvas, bounds.x, bounds.y);
2024
+ }
2025
+ // Draw border
2026
+ ctx.strokeStyle = '#999';
2027
+ ctx.lineWidth = 1;
2028
+ ctx.setLineDash([5, 5]);
2029
+ ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
2030
+ ctx.setLineDash([]);
2031
+ }
2032
+ drawText(ctx, position, text, style) {
2033
+ ctx.font = `${style.fontSize || 16}px ${style.fontFamily || 'system-ui'}`;
2034
+ ctx.fillStyle = style.strokeColor;
2035
+ ctx.textBaseline = 'top';
2036
+ // Draw text background for readability
2037
+ const metrics = ctx.measureText(text);
2038
+ const padding = 4;
2039
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
2040
+ ctx.fillRect(position.x - padding, position.y - padding, metrics.width + padding * 2, (style.fontSize || 16) + padding * 2);
2041
+ // Draw text
2042
+ ctx.fillStyle = style.strokeColor;
2043
+ ctx.fillText(text, position.x, position.y);
2044
+ }
2045
+ drawFreehand(ctx, points) {
2046
+ if (points.length < 2)
2047
+ return;
2048
+ ctx.beginPath();
2049
+ ctx.moveTo(points[0].x, points[0].y);
2050
+ for (let i = 1; i < points.length; i++) {
2051
+ ctx.lineTo(points[i].x, points[i].y);
2052
+ }
2053
+ ctx.stroke();
2054
+ }
2055
+ }
2056
+
2057
+ /**
2058
+ * CheckFlow Feedback Widget
2059
+ * Pure JavaScript widget that works without frameworks
2060
+ */
2061
+ const ICONS = {
2062
+ feedback: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`,
2063
+ close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`,
2064
+ check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24"><polyline points="20 6 9 17 4 12"></polyline></svg>`,
2065
+ camera: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path><circle cx="12" cy="13" r="4"></circle></svg>`,
2066
+ annotate: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><path d="M12 19l7-7 3 3-7 7-3-3z"/><path d="M18 13l-1.5-7.5L2 2l3.5 14.5L13 18l5-5z"/><path d="M2 2l7.586 7.586"/></svg>`,
2067
+ // Material Design inspired SVG icons
2068
+ bug: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M20 8h-2.81a5.985 5.985 0 0 0-1.82-1.96L17 4.41 15.59 3l-2.17 2.17C12.96 5.06 12.49 5 12 5s-.96.06-1.41.17L8.41 3 7 4.41l1.62 1.63C7.88 6.55 7.26 7.22 6.81 8H4v2h2.09c-.05.33-.09.66-.09 1v1H4v2h2v1c0 .34.04.67.09 1H4v2h2.81c1.04 1.79 2.97 3 5.19 3s4.15-1.21 5.19-3H20v-2h-2.09c.05-.33.09-.66.09-1v-1h2v-2h-2v-1c0-.34-.04-.67-.09-1H20V8zm-6 8h-4v-2h4v2zm0-4h-4v-2h4v2z"/></svg>`,
2069
+ feature: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M19 9l1.25-2.75L23 5l-2.75-1.25L19 1l-1.25 2.75L15 5l2.75 1.25L19 9zm-7.5.5L9 4 6.5 9.5 1 12l5.5 2.5L9 20l2.5-5.5L17 12l-5.5-2.5zM19 15l-1.25 2.75L15 19l2.75 1.25L19 23l1.25-2.75L23 19l-2.75-1.25L19 15z"/></svg>`,
2070
+ improvement: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6A4.997 4.997 0 0 1 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z"/></svg>`,
2071
+ question: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/></svg>`,
2072
+ other: `<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>`,
2073
+ };
2074
+ class FeedbackWidget {
2075
+ constructor(options = {}) {
2076
+ this.container = null;
2077
+ this.state = {
2078
+ isOpen: false,
2079
+ isMinimized: false,
2080
+ isCapturing: false,
2081
+ isSubmitting: false,
2082
+ };
2083
+ this.annotationEditor = null;
2084
+ this.currentAnnotations = [];
2085
+ this.options = {
2086
+ position: 'bottom-right',
2087
+ buttonText: 'Feedback',
2088
+ zIndex: 999999,
2089
+ ...options,
2090
+ };
2091
+ // Setup translations
2092
+ const localeTranslations = TRANSLATIONS[options.locale || 'en'] || DEFAULT_TRANSLATIONS;
2093
+ this.translations = { ...localeTranslations, ...options.translations };
2094
+ this.capture = new ContextCapture();
2095
+ }
2096
+ /**
2097
+ * Mount the widget to the DOM
2098
+ */
2099
+ mount() {
2100
+ if (this.container)
2101
+ return;
2102
+ // Create container
2103
+ this.container = document.createElement('div');
2104
+ this.container.className = 'checkflow-widget';
2105
+ this.container.innerHTML = this.renderTrigger();
2106
+ document.body.appendChild(this.container);
2107
+ // Bind events
2108
+ this.bindEvents();
2109
+ // Start capturing context
2110
+ this.capture.startCapture();
2111
+ }
2112
+ /**
2113
+ * Unmount the widget
2114
+ */
2115
+ unmount() {
2116
+ if (this.container) {
2117
+ this.container.remove();
2118
+ this.container = null;
2119
+ }
2120
+ this.capture.stopCapture();
2121
+ }
2122
+ /**
2123
+ * Open the feedback modal
2124
+ */
2125
+ open() {
2126
+ this.state.isOpen = true;
2127
+ this.render();
2128
+ }
2129
+ /**
2130
+ * Close the feedback modal
2131
+ */
2132
+ close() {
2133
+ this.state.isOpen = false;
2134
+ this.state.captureResult = undefined;
2135
+ this.state.error = undefined;
2136
+ this.render();
2137
+ this.options.onClose?.();
2138
+ }
2139
+ renderTrigger() {
2140
+ const { position, buttonText, primaryColor } = this.options;
2141
+ const displayText = buttonText || 'Signaler un bug';
2142
+ // Apply custom color if provided
2143
+ const customStyle = primaryColor ? `style="background-color: ${primaryColor}; color: ${this.getContrastColor(primaryColor)};"` : '';
2144
+ return `
2145
+ <button class="checkflow-trigger checkflow-trigger-pill ${position}" id="cf-trigger" ${customStyle}>
2146
+ <span class="checkflow-trigger-icon">${ICONS.feedback}</span>
2147
+ <span class="checkflow-trigger-text">${displayText}</span>
2148
+ </button>
2149
+ `;
2150
+ }
2151
+ /**
2152
+ * Calculate contrast color (white or black) based on background color
2153
+ */
2154
+ getContrastColor(hexColor) {
2155
+ // Remove # if present
2156
+ const hex = hexColor.replace('#', '');
2157
+ // Convert to RGB
2158
+ const r = parseInt(hex.substr(0, 2), 16);
2159
+ const g = parseInt(hex.substr(2, 2), 16);
2160
+ const b = parseInt(hex.substr(4, 2), 16);
2161
+ // Calculate luminance using relative luminance formula
2162
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
2163
+ // Return black for light colors, white for dark colors
2164
+ return luminance > 0.5 ? '#000000' : '#ffffff';
2165
+ }
2166
+ renderModal() {
2167
+ const t = this.translations;
2168
+ const { captureResult, isSubmitting, error } = this.state;
2169
+ const { position } = this.options;
2170
+ const hasScreenshot = !!captureResult?.screenshot;
2171
+ // Mode expanded avec screenshot: fullscreen avec 2 colonnes au centre
2172
+ if (hasScreenshot) {
2173
+ return this.renderExpandedModal();
2174
+ }
2175
+ // Mode compact: panneau en bas à droite (pas centré)
2176
+ const panelPosition = position?.includes('left') ? 'bottom-left' : '';
2177
+ return `
2178
+ <div class="checkflow-panel ${panelPosition}" id="cf-panel">
2179
+ <div class="checkflow-header">
2180
+ <h3>Envoyer le rapport</h3>
2181
+ <button class="checkflow-close" id="cf-close">${ICONS.close}</button>
2182
+ </div>
2183
+
2184
+ <div class="checkflow-body">
2185
+ ${error ? `<div class="checkflow-error">${error}</div>` : ''}
2186
+
2187
+ <form class="checkflow-form" id="cf-form">
2188
+ <!-- Type Selection -->
2189
+ <div class="checkflow-field">
2190
+ <label class="checkflow-label">Type de retour</label>
2191
+ <div class="checkflow-type-grid">
2192
+ ${this.renderTypeOptions()}
2193
+ </div>
2194
+ </div>
2195
+
2196
+ <!-- Name -->
2197
+ <div class="checkflow-field">
2198
+ <label class="checkflow-label">Nom <span class="cf-required">(obligatoire)</span></label>
2199
+ <input type="text" class="checkflow-input" id="cf-name"
2200
+ placeholder="Votre nom" required>
2201
+ </div>
2202
+
2203
+ <!-- Email -->
2204
+ <div class="checkflow-field">
2205
+ <label class="checkflow-label">Email <span class="cf-required">(obligatoire)</span></label>
2206
+ <input type="email" class="checkflow-input" id="cf-email"
2207
+ placeholder="votre.email@exemple.com" required>
2208
+ </div>
2209
+
2210
+ <!-- Description -->
2211
+ <div class="checkflow-field">
2212
+ <label class="checkflow-label">Description <span class="cf-required">(obligatoire)</span></label>
2213
+ <textarea class="checkflow-textarea" id="cf-description"
2214
+ placeholder="Quel est le problème ? Que vous attendiez-vous à voir ?" required></textarea>
2215
+ </div>
2216
+
2217
+ <!-- Screenshot Button -->
2218
+ <div class="checkflow-field">
2219
+ ${this.renderCaptureButton()}
2220
+ </div>
2221
+
2222
+ <!-- Options -->
2223
+ <div class="checkflow-options">
2224
+ <label class="checkflow-checkbox">
2225
+ <input type="checkbox" id="cf-console" checked>
2226
+ <span class="checkflow-checkbox-label">${t.includeConsole}</span>
2227
+ </label>
2228
+ <label class="checkflow-checkbox">
2229
+ <input type="checkbox" id="cf-network" checked>
2230
+ <span class="checkflow-checkbox-label">${t.includeNetwork}</span>
2231
+ </label>
2232
+ </div>
2233
+ </form>
2234
+ </div>
2235
+
2236
+ <div class="checkflow-footer">
2237
+ <button type="submit" form="cf-form" class="checkflow-btn checkflow-btn-primary"
2238
+ id="cf-submit" ${isSubmitting ? 'disabled' : ''} ${this.options.primaryColor ? `style="background-color: ${this.options.primaryColor}; color: ${this.getContrastColor(this.options.primaryColor)};"` : ''}>
2239
+ ${isSubmitting ? 'Envoi en cours...' : 'Envoyer le rapport'}
2240
+ </button>
2241
+ <button type="button" class="checkflow-btn checkflow-btn-secondary" id="cf-cancel">
2242
+ Annuler
2243
+ </button>
2244
+ </div>
2245
+ <div class="checkflow-powered">Powered by CheckFlow</div>
2246
+ </div>
2247
+ `;
2248
+ }
2249
+ renderExpandedModal() {
2250
+ this.translations;
2251
+ const { captureResult, isSubmitting, error } = this.state;
2252
+ return `
2253
+ <div class="checkflow-overlay checkflow-expanded" id="cf-overlay">
2254
+ <div class="checkflow-modal-expanded">
2255
+ <div class="checkflow-header">
2256
+ <h3>Envoyer le rapport</h3>
2257
+ <button class="checkflow-close" id="cf-close">${ICONS.close}</button>
2258
+ </div>
2259
+
2260
+ <div class="checkflow-content-split">
2261
+ <!-- Left: Screenshot with annotation tools -->
2262
+ <div class="checkflow-screenshot-area">
2263
+ <div class="checkflow-screenshot-container">
2264
+ <img src="${captureResult?.screenshot}" alt="Capture d'écran" class="checkflow-screenshot-large">
2265
+ </div>
2266
+ <div class="checkflow-annotation-toolbar">
2267
+ <button type="button" class="checkflow-tool-btn checkflow-tool-highlight" id="cf-annotate">
2268
+ ${ICONS.annotate}
2269
+ <span>Surligner</span>
2270
+ </button>
2271
+ <button type="button" class="checkflow-tool-btn" id="cf-retake">
2272
+ ${ICONS.camera}
2273
+ <span>Reprendre</span>
2274
+ </button>
2275
+ </div>
2276
+ </div>
2277
+
2278
+ <!-- Right: Form -->
2279
+ <div class="checkflow-form-area">
2280
+ ${error ? `<div class="checkflow-error">${error}</div>` : ''}
2281
+
2282
+ <form class="checkflow-form" id="cf-form">
2283
+ <!-- Type Selection -->
2284
+ <div class="checkflow-field">
2285
+ <label class="checkflow-label">Type de retour</label>
2286
+ <div class="checkflow-type-grid">
2287
+ ${this.renderTypeOptions()}
2288
+ </div>
2289
+ </div>
2290
+
2291
+ <!-- Name -->
2292
+ <div class="checkflow-field">
2293
+ <label class="checkflow-label">Nom <span class="cf-required">(obligatoire)</span></label>
2294
+ <input type="text" class="checkflow-input" id="cf-name"
2295
+ placeholder="Votre nom" required>
2296
+ </div>
2297
+
2298
+ <!-- Email -->
2299
+ <div class="checkflow-field">
2300
+ <label class="checkflow-label">Email <span class="cf-required">(obligatoire)</span></label>
2301
+ <input type="email" class="checkflow-input" id="cf-email"
2302
+ placeholder="votre.email@exemple.com" required>
2303
+ </div>
2304
+
2305
+ <!-- Description -->
2306
+ <div class="checkflow-field">
2307
+ <label class="checkflow-label">Description <span class="cf-required">(obligatoire)</span></label>
2308
+ <textarea class="checkflow-textarea" id="cf-description"
2309
+ placeholder="Quel est le problème ? Que vous attendiez-vous à voir ?" required></textarea>
2310
+ </div>
2311
+
2312
+ <!-- Delete screenshot -->
2313
+ <button type="button" class="checkflow-btn-link" id="cf-delete-screenshot">
2314
+ Supprimer la capture d'écran
2315
+ </button>
2316
+ </form>
2317
+
2318
+ <div class="checkflow-footer-expanded">
2319
+ <button type="submit" form="cf-form" class="checkflow-btn checkflow-btn-primary"
2320
+ id="cf-submit" ${isSubmitting ? 'disabled' : ''} ${this.options.primaryColor ? `style="background-color: ${this.options.primaryColor}; color: ${this.getContrastColor(this.options.primaryColor)};"` : ''}>
2321
+ ${isSubmitting ? 'Envoi en cours...' : 'Envoyer le rapport'}
2322
+ </button>
2323
+ <button type="button" class="checkflow-btn checkflow-btn-secondary" id="cf-cancel">
2324
+ Annuler
2325
+ </button>
2326
+ </div>
2327
+ </div>
2328
+ </div>
2329
+ </div>
2330
+ </div>
2331
+ `;
2332
+ }
2333
+ renderTypeOptions() {
2334
+ const t = this.translations;
2335
+ const types = [
2336
+ { value: 'bug', icon: ICONS.bug, label: t.typeBug },
2337
+ { value: 'feature_request', icon: ICONS.feature, label: t.typeFeature },
2338
+ { value: 'improvement', icon: ICONS.improvement, label: t.typeImprovement },
2339
+ { value: 'question', icon: ICONS.question, label: t.typeQuestion },
2340
+ { value: 'other', icon: ICONS.other, label: t.typeOther },
2341
+ ];
2342
+ return types.map((type, i) => `
2343
+ <label class="checkflow-type-option ${i === 0 ? 'selected' : ''}">
2344
+ <input type="radio" name="cf-type" value="${type.value}" ${i === 0 ? 'checked' : ''} hidden>
2345
+ <span class="checkflow-type-icon">${type.icon}</span>
2346
+ <span class="checkflow-type-label">${type.label}</span>
2347
+ </label>
2348
+ `).join('');
2349
+ }
2350
+ renderCaptureButton() {
2351
+ return `
2352
+ <button type="button" class="checkflow-capture-btn" id="cf-capture">
2353
+ ${ICONS.camera}
2354
+ <span>${this.translations.captureButton}</span>
2355
+ </button>
2356
+ `;
2357
+ }
2358
+ renderScreenshotPreview(screenshot) {
2359
+ return `
2360
+ <div class="checkflow-screenshot">
2361
+ <img src="${screenshot}" alt="Screenshot">
2362
+ <div class="checkflow-screenshot-actions">
2363
+ <button type="button" class="checkflow-screenshot-btn checkflow-annotate-btn" id="cf-annotate">
2364
+ ${ICONS.annotate}
2365
+ <span>Annoter</span>
2366
+ </button>
2367
+ <button type="button" class="checkflow-screenshot-btn" id="cf-retake">
2368
+ ${this.translations.retakeButton}
2369
+ </button>
2370
+ </div>
2371
+ </div>
2372
+ `;
2373
+ }
2374
+ renderSuccess() {
2375
+ const t = this.translations;
2376
+ const { position } = this.options;
2377
+ const panelPosition = position?.includes('left') ? 'bottom-left' : '';
2378
+ return `
2379
+ <div class="checkflow-panel ${panelPosition}" id="cf-panel">
2380
+ <div class="checkflow-body">
2381
+ <div class="checkflow-success">
2382
+ <div class="checkflow-success-icon">${ICONS.check}</div>
2383
+ <h3 class="checkflow-success-title">${t.submitSuccess}</h3>
2384
+ <p class="checkflow-success-message">Merci pour votre retour ! Notre équipe va l'examiner.</p>
2385
+ </div>
2386
+ </div>
2387
+ <div class="checkflow-footer">
2388
+ <button class="checkflow-btn checkflow-btn-primary" id="cf-done">
2389
+ Fermer
2390
+ </button>
2391
+ </div>
2392
+ </div>
2393
+ `;
2394
+ }
2395
+ render() {
2396
+ if (!this.container)
2397
+ return;
2398
+ if (!this.state.isOpen) {
2399
+ this.container.innerHTML = this.renderTrigger();
2400
+ }
2401
+ else {
2402
+ this.container.innerHTML = this.renderTrigger() + this.renderModal();
2403
+ }
2404
+ this.bindEvents();
2405
+ }
2406
+ bindEvents() {
2407
+ if (!this.container)
2408
+ return;
2409
+ // Trigger button
2410
+ const trigger = this.container.querySelector('#cf-trigger');
2411
+ trigger?.addEventListener('click', () => this.open());
2412
+ // Close button
2413
+ const close = this.container.querySelector('#cf-close');
2414
+ close?.addEventListener('click', () => this.close());
2415
+ // Cancel button
2416
+ const cancel = this.container.querySelector('#cf-cancel');
2417
+ cancel?.addEventListener('click', () => this.close());
2418
+ // Done button (success state)
2419
+ const done = this.container.querySelector('#cf-done');
2420
+ done?.addEventListener('click', () => this.close());
2421
+ // Capture button
2422
+ const captureBtn = this.container.querySelector('#cf-capture');
2423
+ captureBtn?.addEventListener('click', () => this.captureScreenshot());
2424
+ // Retake button
2425
+ const retakeBtn = this.container.querySelector('#cf-retake');
2426
+ retakeBtn?.addEventListener('click', () => this.captureScreenshot());
2427
+ // Annotate button
2428
+ const annotateBtn = this.container.querySelector('#cf-annotate');
2429
+ annotateBtn?.addEventListener('click', () => this.openAnnotationEditor());
2430
+ // Delete screenshot button
2431
+ const deleteScreenshotBtn = this.container.querySelector('#cf-delete-screenshot');
2432
+ deleteScreenshotBtn?.addEventListener('click', () => {
2433
+ this.state.captureResult = undefined;
2434
+ this.currentAnnotations = [];
2435
+ this.render();
2436
+ });
2437
+ // Overlay click to close
2438
+ const overlay = this.container.querySelector('#cf-overlay');
2439
+ overlay?.addEventListener('click', (e) => {
2440
+ if (e.target === overlay)
2441
+ this.close();
2442
+ });
2443
+ // Type selection
2444
+ const typeOptions = this.container.querySelectorAll('.checkflow-type-option');
2445
+ typeOptions.forEach((option) => {
2446
+ option.addEventListener('click', () => {
2447
+ typeOptions.forEach((o) => o.classList.remove('selected'));
2448
+ option.classList.add('selected');
2449
+ });
2450
+ });
2451
+ // Form submission
2452
+ const form = this.container.querySelector('#cf-form');
2453
+ form?.addEventListener('submit', (e) => {
2454
+ e.preventDefault();
2455
+ this.handleSubmit();
2456
+ });
2457
+ }
2458
+ async captureScreenshot() {
2459
+ this.state.isCapturing = true;
2460
+ // Temporarily hide widget for clean screenshot
2461
+ if (this.container) {
2462
+ this.container.style.display = 'none';
2463
+ }
2464
+ try {
2465
+ // Use native browser screen capture API (fast, with permission dialog)
2466
+ const screenshot = await this.captureWithDisplayMedia();
2467
+ if (screenshot) {
2468
+ const captureResult = await this.capture.capture({
2469
+ includeConsole: true,
2470
+ includeNetwork: true,
2471
+ includePerformance: true,
2472
+ });
2473
+ captureResult.screenshot = screenshot;
2474
+ this.state.captureResult = captureResult;
2475
+ // Reset annotations when taking new screenshot
2476
+ this.currentAnnotations = [];
2477
+ }
2478
+ }
2479
+ catch (error) {
2480
+ console.error('[CheckFlow] Screenshot failed:', error);
2481
+ // Check if user denied permission
2482
+ if (error.name === 'NotAllowedError') {
2483
+ this.state.error = 'Permission refusée par l\'utilisateur';
2484
+ }
2485
+ else {
2486
+ this.state.error = this.translations.captureError;
2487
+ }
2488
+ }
2489
+ // Restore widget
2490
+ if (this.container) {
2491
+ this.container.style.display = '';
2492
+ }
2493
+ this.state.isCapturing = false;
2494
+ this.render();
2495
+ }
2496
+ /**
2497
+ * Capture screen using native browser API (getDisplayMedia)
2498
+ * Optimized for instant capture after user allows
2499
+ */
2500
+ async captureWithDisplayMedia() {
2501
+ try {
2502
+ // Request screen capture permission
2503
+ const stream = await navigator.mediaDevices.getDisplayMedia({
2504
+ video: {
2505
+ displaySurface: 'browser',
2506
+ },
2507
+ audio: false,
2508
+ // @ts-ignore - preferCurrentTab is a newer API
2509
+ preferCurrentTab: true,
2510
+ });
2511
+ const track = stream.getVideoTracks()[0];
2512
+ // Use ImageCapture API for instant capture (no video element needed)
2513
+ // @ts-ignore - ImageCapture may not be in all TS definitions
2514
+ if (typeof ImageCapture !== 'undefined') {
2515
+ try {
2516
+ // @ts-ignore
2517
+ const imageCapture = new ImageCapture(track);
2518
+ const bitmap = await imageCapture.grabFrame();
2519
+ // Draw bitmap to canvas
2520
+ const canvas = document.createElement('canvas');
2521
+ canvas.width = bitmap.width;
2522
+ canvas.height = bitmap.height;
2523
+ const ctx = canvas.getContext('2d');
2524
+ if (ctx) {
2525
+ ctx.drawImage(bitmap, 0, 0);
2526
+ track.stop();
2527
+ stream.getTracks().forEach(t => t.stop());
2528
+ return canvas.toDataURL('image/png', 0.9);
2529
+ }
2530
+ }
2531
+ catch (e) {
2532
+ // Fall back to video method
2533
+ }
2534
+ }
2535
+ // Fallback: Use video element with minimal delay
2536
+ const video = document.createElement('video');
2537
+ video.srcObject = stream;
2538
+ video.muted = true;
2539
+ video.playsInline = true;
2540
+ await video.play();
2541
+ // Capture immediately after play starts
2542
+ const canvas = document.createElement('canvas');
2543
+ canvas.width = video.videoWidth || 1920;
2544
+ canvas.height = video.videoHeight || 1080;
2545
+ const ctx = canvas.getContext('2d');
2546
+ if (!ctx) {
2547
+ track.stop();
2548
+ throw new Error('Could not get canvas context');
2549
+ }
2550
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
2551
+ // Stop immediately
2552
+ track.stop();
2553
+ stream.getTracks().forEach(t => t.stop());
2554
+ return canvas.toDataURL('image/png', 0.9);
2555
+ }
2556
+ catch (error) {
2557
+ console.error('[CheckFlow] getDisplayMedia failed:', error);
2558
+ throw error;
2559
+ }
2560
+ }
2561
+ /**
2562
+ * Open the annotation editor for the current screenshot
2563
+ */
2564
+ async openAnnotationEditor() {
2565
+ const screenshot = this.state.captureResult?.screenshot;
2566
+ if (!screenshot)
2567
+ return;
2568
+ // Hide the widget modal while annotating
2569
+ if (this.container) {
2570
+ this.container.style.display = 'none';
2571
+ }
2572
+ this.annotationEditor = new AnnotationEditor({
2573
+ tools: ['rectangle', 'ellipse', 'arrow', 'highlight', 'blur', 'text', 'freehand'],
2574
+ defaultStyle: {
2575
+ strokeColor: '#FF3B30',
2576
+ strokeWidth: 3,
2577
+ fillColor: 'transparent',
2578
+ opacity: 1,
2579
+ fontSize: 16,
2580
+ },
2581
+ onSave: (annotations, annotatedImage) => {
2582
+ // Update screenshot with annotated version
2583
+ if (this.state.captureResult) {
2584
+ this.state.captureResult.screenshot = annotatedImage;
2585
+ }
2586
+ this.currentAnnotations = annotations;
2587
+ // Restore and re-render widget
2588
+ if (this.container) {
2589
+ this.container.style.display = '';
2590
+ }
2591
+ this.render();
2592
+ this.annotationEditor = null;
2593
+ },
2594
+ onCancel: () => {
2595
+ // Restore widget without changes
2596
+ if (this.container) {
2597
+ this.container.style.display = '';
2598
+ }
2599
+ this.annotationEditor = null;
2600
+ },
2601
+ });
2602
+ await this.annotationEditor.open(screenshot);
2603
+ }
2604
+ async handleSubmit() {
2605
+ if (!this.container)
2606
+ return;
2607
+ const nameInput = this.container.querySelector('#cf-name');
2608
+ const emailInput = this.container.querySelector('#cf-email');
2609
+ const descInput = this.container.querySelector('#cf-description');
2610
+ const typeInput = this.container.querySelector('input[name="cf-type"]:checked');
2611
+ const name = nameInput?.value.trim();
2612
+ const email = emailInput?.value.trim();
2613
+ const description = descInput?.value.trim();
2614
+ const feedbackType = (typeInput?.value || 'bug');
2615
+ // Validation
2616
+ if (!name) {
2617
+ this.state.error = 'Veuillez entrer votre nom';
2618
+ this.render();
2619
+ return;
2620
+ }
2621
+ if (!email) {
2622
+ this.state.error = 'Veuillez entrer votre email';
2623
+ this.render();
2624
+ return;
2625
+ }
2626
+ if (!description) {
2627
+ this.state.error = 'Veuillez décrire le problème';
2628
+ this.render();
2629
+ return;
2630
+ }
2631
+ this.state.isSubmitting = true;
2632
+ this.state.error = undefined;
2633
+ this.render();
2634
+ try {
2635
+ const submitData = {
2636
+ name,
2637
+ email,
2638
+ description,
2639
+ type: feedbackType,
2640
+ priority: 'medium',
2641
+ screenshot: this.state.captureResult?.screenshot,
2642
+ annotations: this.currentAnnotations.length > 0 ? this.currentAnnotations : undefined,
2643
+ includeConsole: true,
2644
+ includeNetwork: true,
2645
+ };
2646
+ if (this.options.onSubmit) {
2647
+ await this.options.onSubmit(submitData);
2648
+ }
2649
+ // Show success
2650
+ this.state.isSubmitting = false;
2651
+ this.container.innerHTML = this.renderTrigger() + this.renderSuccess();
2652
+ this.bindEvents();
2653
+ // Auto-close after delay
2654
+ setTimeout(() => this.close(), 3000);
2655
+ }
2656
+ catch (error) {
2657
+ this.state.isSubmitting = false;
2658
+ this.state.error = error.message || this.translations.submitError;
2659
+ this.render();
2660
+ }
2661
+ }
2662
+ /**
2663
+ * Update widget options
2664
+ */
2665
+ setOptions(options) {
2666
+ this.options = { ...this.options, ...options };
2667
+ if (options.translations) {
2668
+ this.translations = { ...this.translations, ...options.translations };
2669
+ }
2670
+ this.render();
2671
+ }
2672
+ /**
2673
+ * Get capture context
2674
+ */
2675
+ getCapture() {
2676
+ return this.capture;
2677
+ }
2678
+ }
2679
+
2680
+ /**
2681
+ * Session Recording Module
2682
+ * Captures user interactions for replay
2683
+ */
2684
+ const DEFAULT_CONFIG = {
2685
+ enabled: true,
2686
+ recordMouse: true,
2687
+ recordScroll: true,
2688
+ recordInput: true,
2689
+ recordMutations: true,
2690
+ maskInputs: true,
2691
+ maskSelectors: ['[type="password"]', '[data-sensitive]', '.sensitive'],
2692
+ maxDurationSeconds: 300,
2693
+ sampleRate: 1.0,
2694
+ };
2695
+ class SessionRecording {
2696
+ constructor(config = {}) {
2697
+ this.events = [];
2698
+ this.startTime = 0;
2699
+ this.isRecording = false;
2700
+ this.mutationObserver = null;
2701
+ this.listeners = [];
2702
+ this.config = { ...DEFAULT_CONFIG, ...config };
2703
+ this.sessionId = this.generateSessionId();
2704
+ }
2705
+ generateSessionId() {
2706
+ return `sess_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
2707
+ }
2708
+ /**
2709
+ * Start recording the session
2710
+ */
2711
+ start() {
2712
+ if (this.isRecording || !this.config.enabled)
2713
+ return;
2714
+ this.isRecording = true;
2715
+ this.startTime = Date.now();
2716
+ this.events = [];
2717
+ // Record initial DOM snapshot
2718
+ this.recordSnapshot();
2719
+ // Setup event listeners
2720
+ if (this.config.recordMouse) {
2721
+ this.setupMouseTracking();
2722
+ }
2723
+ if (this.config.recordScroll) {
2724
+ this.setupScrollTracking();
2725
+ }
2726
+ if (this.config.recordInput) {
2727
+ this.setupInputTracking();
2728
+ }
2729
+ if (this.config.recordMutations) {
2730
+ this.setupMutationObserver();
2731
+ }
2732
+ // Track navigation
2733
+ this.setupNavigationTracking();
2734
+ // Track resize
2735
+ this.setupResizeTracking();
2736
+ // Auto-stop after max duration
2737
+ setTimeout(() => {
2738
+ if (this.isRecording) {
2739
+ this.stop();
2740
+ }
2741
+ }, this.config.maxDurationSeconds * 1000);
2742
+ }
2743
+ /**
2744
+ * Stop recording
2745
+ */
2746
+ stop() {
2747
+ if (!this.isRecording)
2748
+ return;
2749
+ this.isRecording = false;
2750
+ // Remove all event listeners
2751
+ this.listeners.forEach(({ element, event, handler }) => {
2752
+ element.removeEventListener(event, handler);
2753
+ });
2754
+ this.listeners = [];
2755
+ // Disconnect mutation observer
2756
+ if (this.mutationObserver) {
2757
+ this.mutationObserver.disconnect();
2758
+ this.mutationObserver = null;
2759
+ }
2760
+ }
2761
+ /**
2762
+ * Get session ID
2763
+ */
2764
+ getSessionId() {
2765
+ return this.sessionId;
2766
+ }
2767
+ /**
2768
+ * Get recorded events
2769
+ */
2770
+ getEvents() {
2771
+ return [...this.events];
2772
+ }
2773
+ /**
2774
+ * Get recording duration in seconds
2775
+ */
2776
+ getDuration() {
2777
+ if (!this.startTime)
2778
+ return 0;
2779
+ return Math.round((Date.now() - this.startTime) / 1000);
2780
+ }
2781
+ /**
2782
+ * Clear events (for chunked uploads)
2783
+ */
2784
+ clearEvents() {
2785
+ this.events = [];
2786
+ }
2787
+ /**
2788
+ * Get recording data for submission
2789
+ */
2790
+ getRecordingData() {
2791
+ if (this.events.length === 0)
2792
+ return null;
2793
+ return {
2794
+ events: [...this.events],
2795
+ sessionId: this.sessionId,
2796
+ duration: this.getDuration(),
2797
+ };
2798
+ }
2799
+ addEvent(type, data) {
2800
+ // Sample rate filtering
2801
+ if (Math.random() > this.config.sampleRate)
2802
+ return;
2803
+ this.events.push({
2804
+ type,
2805
+ timestamp: Date.now() - this.startTime,
2806
+ data,
2807
+ });
2808
+ // Limit event buffer size
2809
+ if (this.events.length > 10000) {
2810
+ this.events.shift();
2811
+ }
2812
+ }
2813
+ recordSnapshot() {
2814
+ // Record initial page state
2815
+ this.addEvent('mutation', {
2816
+ action: 'snapshot',
2817
+ html: this.sanitizeHTML(document.documentElement.outerHTML),
2818
+ url: window.location.href,
2819
+ title: document.title,
2820
+ viewport: {
2821
+ width: window.innerWidth,
2822
+ height: window.innerHeight,
2823
+ },
2824
+ });
2825
+ }
2826
+ setupMouseTracking() {
2827
+ let lastX = 0, lastY = 0;
2828
+ let throttleTimer = null;
2829
+ const handler = (e) => {
2830
+ // Throttle to ~30fps
2831
+ if (throttleTimer)
2832
+ return;
2833
+ throttleTimer = window.setTimeout(() => {
2834
+ throttleTimer = null;
2835
+ }, 33);
2836
+ // Only record if position changed significantly
2837
+ if (Math.abs(e.clientX - lastX) > 5 || Math.abs(e.clientY - lastY) > 5) {
2838
+ lastX = e.clientX;
2839
+ lastY = e.clientY;
2840
+ this.addEvent('mouse', {
2841
+ action: 'move',
2842
+ x: e.clientX,
2843
+ y: e.clientY,
2844
+ });
2845
+ }
2846
+ };
2847
+ const clickHandler = (e) => {
2848
+ this.addEvent('mouse', {
2849
+ action: 'click',
2850
+ x: e.clientX,
2851
+ y: e.clientY,
2852
+ target: this.getElementSelector(e.target),
2853
+ });
2854
+ };
2855
+ document.addEventListener('mousemove', handler, { passive: true });
2856
+ document.addEventListener('click', clickHandler, { passive: true });
2857
+ this.listeners.push({ element: document, event: 'mousemove', handler: handler }, { element: document, event: 'click', handler: clickHandler });
2858
+ }
2859
+ setupScrollTracking() {
2860
+ let throttleTimer = null;
2861
+ const handler = () => {
2862
+ if (throttleTimer)
2863
+ return;
2864
+ throttleTimer = window.setTimeout(() => {
2865
+ throttleTimer = null;
2866
+ }, 100);
2867
+ this.addEvent('scroll', {
2868
+ x: window.scrollX,
2869
+ y: window.scrollY,
2870
+ });
2871
+ };
2872
+ window.addEventListener('scroll', handler, { passive: true });
2873
+ this.listeners.push({ element: window, event: 'scroll', handler });
2874
+ }
2875
+ setupInputTracking() {
2876
+ const handler = (e) => {
2877
+ const target = e.target;
2878
+ if (!target.tagName)
2879
+ return;
2880
+ const isSensitive = this.isSensitiveElement(target);
2881
+ this.addEvent('input', {
2882
+ selector: this.getElementSelector(target),
2883
+ value: isSensitive ? '••••••••' : target.value?.substring(0, 100),
2884
+ type: target.type || 'text',
2885
+ });
2886
+ };
2887
+ document.addEventListener('input', handler, { passive: true });
2888
+ this.listeners.push({ element: document, event: 'input', handler });
2889
+ }
2890
+ setupMutationObserver() {
2891
+ this.mutationObserver = new MutationObserver((mutations) => {
2892
+ const changes = [];
2893
+ mutations.forEach((mutation) => {
2894
+ if (mutation.type === 'childList') {
2895
+ mutation.addedNodes.forEach((node) => {
2896
+ if (node.nodeType === Node.ELEMENT_NODE) {
2897
+ changes.push({
2898
+ action: 'add',
2899
+ target: this.getElementSelector(mutation.target),
2900
+ html: this.sanitizeHTML(node.outerHTML || ''),
2901
+ });
2902
+ }
2903
+ });
2904
+ mutation.removedNodes.forEach((node) => {
2905
+ if (node.nodeType === Node.ELEMENT_NODE) {
2906
+ changes.push({
2907
+ action: 'remove',
2908
+ target: this.getElementSelector(mutation.target),
2909
+ selector: this.getElementSelector(node),
2910
+ });
2911
+ }
2912
+ });
2913
+ }
2914
+ else if (mutation.type === 'attributes') {
2915
+ changes.push({
2916
+ action: 'attribute',
2917
+ target: this.getElementSelector(mutation.target),
2918
+ attribute: mutation.attributeName,
2919
+ value: mutation.target.getAttribute(mutation.attributeName),
2920
+ });
2921
+ }
2922
+ });
2923
+ if (changes.length > 0) {
2924
+ this.addEvent('mutation', { changes });
2925
+ }
2926
+ });
2927
+ this.mutationObserver.observe(document.body, {
2928
+ childList: true,
2929
+ subtree: true,
2930
+ attributes: true,
2931
+ attributeFilter: ['class', 'style', 'src', 'href'],
2932
+ });
2933
+ }
2934
+ setupNavigationTracking() {
2935
+ const handler = () => {
2936
+ this.addEvent('navigation', {
2937
+ url: window.location.href,
2938
+ title: document.title,
2939
+ });
2940
+ };
2941
+ window.addEventListener('popstate', handler);
2942
+ this.listeners.push({ element: window, event: 'popstate', handler });
2943
+ }
2944
+ setupResizeTracking() {
2945
+ let throttleTimer = null;
2946
+ const handler = () => {
2947
+ if (throttleTimer)
2948
+ return;
2949
+ throttleTimer = window.setTimeout(() => {
2950
+ throttleTimer = null;
2951
+ }, 200);
2952
+ this.addEvent('resize', {
2953
+ width: window.innerWidth,
2954
+ height: window.innerHeight,
2955
+ });
2956
+ };
2957
+ window.addEventListener('resize', handler, { passive: true });
2958
+ this.listeners.push({ element: window, event: 'resize', handler });
2959
+ }
2960
+ getElementSelector(element) {
2961
+ if (!element || !element.tagName)
2962
+ return '';
2963
+ const parts = [];
2964
+ let current = element;
2965
+ while (current && current.tagName !== 'HTML') {
2966
+ let selector = current.tagName.toLowerCase();
2967
+ if (current.id) {
2968
+ selector += `#${current.id}`;
2969
+ parts.unshift(selector);
2970
+ break;
2971
+ }
2972
+ else if (current.className && typeof current.className === 'string') {
2973
+ const classes = current.className.trim().split(/\s+/).slice(0, 2);
2974
+ if (classes.length > 0 && classes[0]) {
2975
+ selector += `.${classes.join('.')}`;
2976
+ }
2977
+ }
2978
+ parts.unshift(selector);
2979
+ current = current.parentElement;
2980
+ }
2981
+ return parts.slice(-4).join(' > ');
2982
+ }
2983
+ isSensitiveElement(element) {
2984
+ if (this.config.maskInputs) {
2985
+ const type = element.getAttribute('type');
2986
+ if (type === 'password' || type === 'credit-card')
2987
+ return true;
2988
+ }
2989
+ return this.config.maskSelectors.some((selector) => {
2990
+ try {
2991
+ return element.matches(selector);
2992
+ }
2993
+ catch {
2994
+ return false;
2995
+ }
2996
+ });
2997
+ }
2998
+ sanitizeHTML(html) {
2999
+ // Remove script contents
3000
+ html = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '<script></script>');
3001
+ // Mask sensitive inputs
3002
+ if (this.config.maskInputs) {
3003
+ html = html.replace(/(<input[^>]*type=["']password["'][^>]*value=["'])[^"']*["']/gi, '$1••••••••"');
3004
+ }
3005
+ // Truncate if too large
3006
+ if (html.length > 500000) {
3007
+ html = html.substring(0, 500000) + '<!-- truncated -->';
3008
+ }
3009
+ return html;
3010
+ }
3011
+ }
3012
+
3013
+ /**
3014
+ * CheckFlow Privacy Module Types
3015
+ * Configuration and types for automatic PII masking
3016
+ */
3017
+ const DEFAULT_PRIVACY_CONFIG = {
3018
+ enabled: true,
3019
+ autoMask: {
3020
+ emails: true,
3021
+ creditCards: true,
3022
+ phoneNumbers: true,
3023
+ passwords: true,
3024
+ socialSecurity: true,
3025
+ ipAddresses: false,
3026
+ customPatterns: [],
3027
+ },
3028
+ excludeSelectors: [],
3029
+ includeSelectors: [],
3030
+ maskChar: '•',
3031
+ maskLength: 'preserve',
3032
+ fixedMaskLength: 8,
3033
+ };
3034
+ const SENSITIVE_INPUT_TYPES = [
3035
+ 'password',
3036
+ 'email',
3037
+ 'tel',
3038
+ 'credit-card',
3039
+ 'cc-number',
3040
+ 'cc-csc',
3041
+ 'cc-exp',
3042
+ ];
3043
+ const SENSITIVE_AUTOCOMPLETE_VALUES = [
3044
+ 'cc-number',
3045
+ 'cc-csc',
3046
+ 'cc-exp',
3047
+ 'cc-exp-month',
3048
+ 'cc-exp-year',
3049
+ 'cc-name',
3050
+ 'cc-type',
3051
+ 'new-password',
3052
+ 'current-password',
3053
+ ];
3054
+
3055
+ /**
3056
+ * CheckFlow Privacy Detector
3057
+ * Regex patterns for detecting PII (Personally Identifiable Information)
3058
+ */
3059
+ const PATTERNS = {
3060
+ email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
3061
+ creditCard: /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b/g,
3062
+ creditCardSpaced: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
3063
+ phone: /(?:\+?1[-.\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g,
3064
+ phoneFR: /(?:(?:\+|00)33|0)\s*[1-9](?:[\s.-]*\d{2}){4}/g,
3065
+ ssn: /\b\d{3}[-]?\d{2}[-]?\d{4}\b/g,
3066
+ ipv4: /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/g,
3067
+ ipv6: /\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b/g,
3068
+ };
3069
+ class PrivacyDetector {
3070
+ constructor(customPatterns = []) {
3071
+ this.customPatterns = [];
3072
+ this.customPatterns = customPatterns;
3073
+ }
3074
+ /**
3075
+ * Add a custom pattern
3076
+ */
3077
+ addPattern(pattern) {
3078
+ this.customPatterns.push(pattern);
3079
+ }
3080
+ /**
3081
+ * Detect all PII in a text string
3082
+ */
3083
+ detectAll(text, options = {}) {
3084
+ const detections = [];
3085
+ const { emails = true, creditCards = true, phoneNumbers = true, ssn = true, ipAddresses = false, } = options;
3086
+ if (emails) {
3087
+ detections.push(...this.detectEmails(text));
3088
+ }
3089
+ if (creditCards) {
3090
+ detections.push(...this.detectCreditCards(text));
3091
+ }
3092
+ if (phoneNumbers) {
3093
+ detections.push(...this.detectPhoneNumbers(text));
3094
+ }
3095
+ if (ssn) {
3096
+ detections.push(...this.detectSSN(text));
3097
+ }
3098
+ if (ipAddresses) {
3099
+ detections.push(...this.detectIPAddresses(text));
3100
+ }
3101
+ // Custom patterns
3102
+ for (const custom of this.customPatterns) {
3103
+ detections.push(...this.detectCustom(text, custom));
3104
+ }
3105
+ // Sort by start index and remove overlaps
3106
+ return this.removeOverlaps(detections);
3107
+ }
3108
+ /**
3109
+ * Detect email addresses
3110
+ */
3111
+ detectEmails(text) {
3112
+ return this.findMatches(text, PATTERNS.email, 'email');
3113
+ }
3114
+ /**
3115
+ * Detect credit card numbers
3116
+ */
3117
+ detectCreditCards(text) {
3118
+ const detections = [];
3119
+ // Standard format
3120
+ detections.push(...this.findMatches(text, PATTERNS.creditCard, 'credit_card'));
3121
+ // Spaced/dashed format
3122
+ detections.push(...this.findMatches(text, PATTERNS.creditCardSpaced, 'credit_card'));
3123
+ return detections;
3124
+ }
3125
+ /**
3126
+ * Detect phone numbers
3127
+ */
3128
+ detectPhoneNumbers(text) {
3129
+ const detections = [];
3130
+ // US format
3131
+ detections.push(...this.findMatches(text, PATTERNS.phone, 'phone'));
3132
+ // French format
3133
+ detections.push(...this.findMatches(text, PATTERNS.phoneFR, 'phone'));
3134
+ return detections;
3135
+ }
3136
+ /**
3137
+ * Detect Social Security Numbers
3138
+ */
3139
+ detectSSN(text) {
3140
+ return this.findMatches(text, PATTERNS.ssn, 'ssn');
3141
+ }
3142
+ /**
3143
+ * Detect IP addresses
3144
+ */
3145
+ detectIPAddresses(text) {
3146
+ const detections = [];
3147
+ detections.push(...this.findMatches(text, PATTERNS.ipv4, 'ip_address'));
3148
+ detections.push(...this.findMatches(text, PATTERNS.ipv6, 'ip_address'));
3149
+ return detections;
3150
+ }
3151
+ /**
3152
+ * Detect custom pattern
3153
+ */
3154
+ detectCustom(text, pattern) {
3155
+ return this.findMatches(text, pattern.pattern, 'custom');
3156
+ }
3157
+ /**
3158
+ * Find all matches of a pattern in text
3159
+ */
3160
+ findMatches(text, pattern, type) {
3161
+ const detections = [];
3162
+ const regex = new RegExp(pattern.source, pattern.flags);
3163
+ let match;
3164
+ while ((match = regex.exec(text)) !== null) {
3165
+ detections.push({
3166
+ type,
3167
+ value: match[0],
3168
+ masked: '', // Will be filled by masker
3169
+ startIndex: match.index,
3170
+ endIndex: match.index + match[0].length,
3171
+ });
3172
+ }
3173
+ return detections;
3174
+ }
3175
+ /**
3176
+ * Remove overlapping detections (keep the longer one)
3177
+ */
3178
+ removeOverlaps(detections) {
3179
+ if (detections.length <= 1)
3180
+ return detections;
3181
+ // Sort by start index
3182
+ const sorted = [...detections].sort((a, b) => a.startIndex - b.startIndex);
3183
+ const result = [sorted[0]];
3184
+ for (let i = 1; i < sorted.length; i++) {
3185
+ const current = sorted[i];
3186
+ const last = result[result.length - 1];
3187
+ // Check for overlap
3188
+ if (current.startIndex < last.endIndex) {
3189
+ // Keep the longer one
3190
+ if (current.endIndex - current.startIndex > last.endIndex - last.startIndex) {
3191
+ result[result.length - 1] = current;
3192
+ }
3193
+ }
3194
+ else {
3195
+ result.push(current);
3196
+ }
3197
+ }
3198
+ return result;
3199
+ }
3200
+ }
3201
+
3202
+ /**
3203
+ * CheckFlow Privacy Masker
3204
+ * Applies masking to detected PII in text and DOM elements
3205
+ */
3206
+ class PrivacyMasker {
3207
+ constructor(config = {}) {
3208
+ this.config = { ...DEFAULT_PRIVACY_CONFIG, ...config };
3209
+ this.detector = new PrivacyDetector(this.config.autoMask.customPatterns);
3210
+ }
3211
+ /**
3212
+ * Update configuration
3213
+ */
3214
+ configure(config) {
3215
+ this.config = { ...this.config, ...config };
3216
+ if (config.autoMask?.customPatterns) {
3217
+ this.detector = new PrivacyDetector(config.autoMask.customPatterns);
3218
+ }
3219
+ }
3220
+ /**
3221
+ * Mask PII in a text string
3222
+ */
3223
+ maskText(text) {
3224
+ if (!this.config.enabled || !text) {
3225
+ return { original: text, masked: text, detections: [] };
3226
+ }
3227
+ const detections = this.detector.detectAll(text, {
3228
+ emails: this.config.autoMask.emails,
3229
+ creditCards: this.config.autoMask.creditCards,
3230
+ phoneNumbers: this.config.autoMask.phoneNumbers,
3231
+ ssn: this.config.autoMask.socialSecurity,
3232
+ ipAddresses: this.config.autoMask.ipAddresses,
3233
+ });
3234
+ if (detections.length === 0) {
3235
+ return { original: text, masked: text, detections: [] };
3236
+ }
3237
+ // Apply masking
3238
+ let masked = text;
3239
+ let offset = 0;
3240
+ for (const detection of detections) {
3241
+ const maskValue = this.getMaskValue(detection.value);
3242
+ detection.masked = maskValue;
3243
+ const start = detection.startIndex + offset;
3244
+ const end = detection.endIndex + offset;
3245
+ masked = masked.substring(0, start) + maskValue + masked.substring(end);
3246
+ offset += maskValue.length - detection.value.length;
3247
+ }
3248
+ return { original: text, masked, detections };
3249
+ }
3250
+ /**
3251
+ * Generate mask value for a detected PII
3252
+ */
3253
+ getMaskValue(value) {
3254
+ const { maskChar, maskLength, fixedMaskLength } = this.config;
3255
+ if (maskLength === 'fixed') {
3256
+ return maskChar.repeat(fixedMaskLength);
3257
+ }
3258
+ // Preserve length - show first and last chars for context
3259
+ if (value.length <= 4) {
3260
+ return maskChar.repeat(value.length);
3261
+ }
3262
+ // For emails, preserve domain
3263
+ if (value.includes('@')) {
3264
+ const [local, domain] = value.split('@');
3265
+ const maskedLocal = local[0] + maskChar.repeat(Math.max(local.length - 2, 1)) + (local.length > 1 ? local[local.length - 1] : '');
3266
+ return `${maskedLocal}@${domain}`;
3267
+ }
3268
+ // For credit cards, show last 4 digits
3269
+ if (value.replace(/[\s-]/g, '').length >= 13) {
3270
+ const digits = value.replace(/[\s-]/g, '');
3271
+ return maskChar.repeat(digits.length - 4) + digits.slice(-4);
3272
+ }
3273
+ // Default: mask middle, show first and last
3274
+ return value[0] + maskChar.repeat(value.length - 2) + value[value.length - 1];
3275
+ }
3276
+ /**
3277
+ * Process DOM elements for privacy masking
3278
+ * Returns a cloned document with masked content
3279
+ */
3280
+ processDOM(doc) {
3281
+ const result = {
3282
+ elementsProcessed: 0,
3283
+ elementsMasked: 0,
3284
+ detections: [],
3285
+ };
3286
+ if (!this.config.enabled) {
3287
+ return result;
3288
+ }
3289
+ // Process text nodes
3290
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT, null);
3291
+ const textNodes = [];
3292
+ let node;
3293
+ while ((node = walker.nextNode())) {
3294
+ textNodes.push(node);
3295
+ }
3296
+ for (const textNode of textNodes) {
3297
+ result.elementsProcessed++;
3298
+ const text = textNode.textContent || '';
3299
+ if (!text.trim())
3300
+ continue;
3301
+ // Skip if in excluded selector
3302
+ if (this.isExcluded(textNode.parentElement))
3303
+ continue;
3304
+ const maskResult = this.maskText(text);
3305
+ if (maskResult.detections.length > 0) {
3306
+ textNode.textContent = maskResult.masked;
3307
+ result.elementsMasked++;
3308
+ for (const detection of maskResult.detections) {
3309
+ result.detections.push({
3310
+ selector: this.getSelector(textNode.parentElement),
3311
+ type: detection.type,
3312
+ originalText: detection.value,
3313
+ maskedText: detection.masked,
3314
+ });
3315
+ }
3316
+ }
3317
+ }
3318
+ // Process input values
3319
+ const inputs = doc.querySelectorAll('input, textarea');
3320
+ inputs.forEach((input) => {
3321
+ result.elementsProcessed++;
3322
+ const el = input;
3323
+ // Check if sensitive input type
3324
+ if (this.isSensitiveInput(el)) {
3325
+ const value = el.value;
3326
+ if (value) {
3327
+ el.value = this.config.maskChar.repeat(Math.min(value.length, 12));
3328
+ result.elementsMasked++;
3329
+ result.detections.push({
3330
+ selector: this.getSelector(el),
3331
+ type: 'password',
3332
+ originalText: '[REDACTED]',
3333
+ maskedText: el.value,
3334
+ });
3335
+ }
3336
+ }
3337
+ else if (!this.isExcluded(el)) {
3338
+ // Mask PII in regular inputs
3339
+ const maskResult = this.maskText(el.value);
3340
+ if (maskResult.detections.length > 0) {
3341
+ el.value = maskResult.masked;
3342
+ result.elementsMasked++;
3343
+ for (const detection of maskResult.detections) {
3344
+ result.detections.push({
3345
+ selector: this.getSelector(el),
3346
+ type: detection.type,
3347
+ originalText: detection.value,
3348
+ maskedText: detection.masked,
3349
+ });
3350
+ }
3351
+ }
3352
+ }
3353
+ });
3354
+ // Process data attributes that might contain PII
3355
+ const elementsWithData = doc.querySelectorAll('[data-email], [data-phone], [data-user]');
3356
+ elementsWithData.forEach((el) => {
3357
+ result.elementsProcessed++;
3358
+ if (this.isExcluded(el))
3359
+ return;
3360
+ const attrs = ['data-email', 'data-phone', 'data-user'];
3361
+ for (const attr of attrs) {
3362
+ const value = el.getAttribute(attr);
3363
+ if (value) {
3364
+ const maskResult = this.maskText(value);
3365
+ if (maskResult.detections.length > 0) {
3366
+ el.setAttribute(attr, maskResult.masked);
3367
+ result.elementsMasked++;
3368
+ }
3369
+ }
3370
+ }
3371
+ });
3372
+ return result;
3373
+ }
3374
+ /**
3375
+ * Check if element should be excluded from masking
3376
+ */
3377
+ isExcluded(element) {
3378
+ if (!element)
3379
+ return false;
3380
+ // Check exclude selectors
3381
+ for (const selector of this.config.excludeSelectors) {
3382
+ if (element.matches(selector) || element.closest(selector)) {
3383
+ return true;
3384
+ }
3385
+ }
3386
+ // Check data-checkflow-ignore attribute
3387
+ if (element.hasAttribute('data-checkflow-ignore') ||
3388
+ element.closest('[data-checkflow-ignore]')) {
3389
+ return true;
3390
+ }
3391
+ return false;
3392
+ }
3393
+ /**
3394
+ * Check if input is a sensitive type
3395
+ */
3396
+ isSensitiveInput(input) {
3397
+ const type = input.getAttribute('type') || 'text';
3398
+ const autocomplete = input.getAttribute('autocomplete') || '';
3399
+ const name = input.getAttribute('name') || '';
3400
+ // Check type
3401
+ if (SENSITIVE_INPUT_TYPES.includes(type.toLowerCase())) {
3402
+ return true;
3403
+ }
3404
+ // Check autocomplete
3405
+ if (SENSITIVE_AUTOCOMPLETE_VALUES.some(val => autocomplete.toLowerCase().includes(val))) {
3406
+ return true;
3407
+ }
3408
+ // Check name patterns
3409
+ const sensitiveNames = ['password', 'pwd', 'pass', 'secret', 'credit', 'card', 'cvv', 'cvc', 'ssn'];
3410
+ if (sensitiveNames.some(n => name.toLowerCase().includes(n))) {
3411
+ return true;
3412
+ }
3413
+ return false;
3414
+ }
3415
+ /**
3416
+ * Generate CSS selector for element
3417
+ */
3418
+ getSelector(element) {
3419
+ if (!element)
3420
+ return '';
3421
+ if (element.id) {
3422
+ return `#${element.id}`;
3423
+ }
3424
+ const path = [];
3425
+ let current = element;
3426
+ while (current && current !== document.body) {
3427
+ let selector = current.tagName.toLowerCase();
3428
+ if (current.className) {
3429
+ const classes = current.className.toString().trim().split(/\s+/).slice(0, 2);
3430
+ if (classes.length) {
3431
+ selector += '.' + classes.join('.');
3432
+ }
3433
+ }
3434
+ path.unshift(selector);
3435
+ current = current.parentElement;
3436
+ if (path.length >= 3)
3437
+ break;
3438
+ }
3439
+ return path.join(' > ');
3440
+ }
3441
+ /**
3442
+ * Get current configuration
3443
+ */
3444
+ getConfig() {
3445
+ return { ...this.config };
3446
+ }
3447
+ }
3448
+
3449
+ /**
3450
+ * Analytics Tracker for CheckFlow SDK
3451
+ * Automatically tracks user sessions and interactions for analytics
3452
+ */
3453
+ // ==================== Analytics Tracker ====================
3454
+ class AnalyticsTracker {
3455
+ constructor(apiClient, options = {}) {
3456
+ this.isActive = false;
3457
+ this.currentPageUrl = '';
3458
+ this.pageLoadStart = 0;
3459
+ // Batching
3460
+ this.interactionBuffer = [];
3461
+ this.batchTimer = null;
3462
+ // Event tracking state
3463
+ this.scrollEvents = 0;
3464
+ this.lastScrollTime = 0;
3465
+ this.isFirstPageView = true;
3466
+ // ==================== Private Methods - Event Handlers ====================
3467
+ this.handleClick = (event) => {
3468
+ const target = event.target;
3469
+ if (!target)
3470
+ return;
3471
+ const interaction = {
3472
+ pageUrl: window.location.href,
3473
+ pageTitle: document.title,
3474
+ eventType: 'click',
3475
+ elementSelector: this.getElementSelector(target),
3476
+ elementText: this.getElementText(target),
3477
+ mouseX: event.clientX,
3478
+ mouseY: event.clientY
3479
+ };
3480
+ this.queueInteraction(interaction);
3481
+ };
3482
+ this.handleScroll = () => {
3483
+ const now = Date.now();
3484
+ // Throttle scroll events
3485
+ if (now - this.lastScrollTime < this.options.throttleScrollMs)
3486
+ return;
3487
+ if (this.scrollEvents >= this.options.maxScrollEvents)
3488
+ return;
3489
+ this.lastScrollTime = now;
3490
+ this.scrollEvents++;
3491
+ const scrollDepth = this.calculateScrollDepth();
3492
+ const interaction = {
3493
+ pageUrl: window.location.href,
3494
+ pageTitle: document.title,
3495
+ eventType: 'scroll',
3496
+ scrollDepth: scrollDepth
3497
+ };
3498
+ this.queueInteraction(interaction);
3499
+ };
3500
+ this.handleFormFocus = (event) => {
3501
+ const target = event.target;
3502
+ if (!this.isFormElement(target))
3503
+ return;
3504
+ const interaction = {
3505
+ pageUrl: window.location.href,
3506
+ pageTitle: document.title,
3507
+ eventType: 'form_interaction',
3508
+ elementSelector: this.getElementSelector(target),
3509
+ elementText: this.getElementText(target)
3510
+ };
3511
+ this.queueInteraction(interaction);
3512
+ };
3513
+ this.handleFormChange = (event) => {
3514
+ const target = event.target;
3515
+ if (!this.isFormElement(target))
3516
+ return;
3517
+ // Don't capture actual form values for privacy
3518
+ const interaction = {
3519
+ pageUrl: window.location.href,
3520
+ pageTitle: document.title,
3521
+ eventType: 'form_interaction',
3522
+ elementSelector: this.getElementSelector(target)
3523
+ };
3524
+ this.queueInteraction(interaction);
3525
+ };
3526
+ this.handleError = (event) => {
3527
+ const interaction = {
3528
+ pageUrl: window.location.href,
3529
+ pageTitle: document.title,
3530
+ eventType: 'error',
3531
+ errorMessage: event.message,
3532
+ errorStack: event.error?.stack
3533
+ };
3534
+ this.queueInteraction(interaction);
3535
+ };
3536
+ this.handleUnhandledRejection = (event) => {
3537
+ const interaction = {
3538
+ pageUrl: window.location.href,
3539
+ pageTitle: document.title,
3540
+ eventType: 'error',
3541
+ errorMessage: `Unhandled Promise Rejection: ${event.reason}`,
3542
+ errorStack: event.reason?.stack
3543
+ };
3544
+ this.queueInteraction(interaction);
3545
+ };
3546
+ this.handlePageUnload = () => {
3547
+ // Send any pending interactions synchronously
3548
+ if (this.interactionBuffer.length > 0) {
3549
+ // Use sendBeacon for reliable delivery
3550
+ const data = JSON.stringify({ interactions: this.interactionBuffer });
3551
+ const url = `${this.apiClient.getBaseUrl()}/api/v1/analytics/sessions/${this.sessionId}/interactions/batch`;
3552
+ if (navigator.sendBeacon) {
3553
+ navigator.sendBeacon(url, data);
3554
+ }
3555
+ }
3556
+ };
3557
+ this.handleVisibilityChange = () => {
3558
+ if (document.hidden) {
3559
+ // Page became hidden - flush interactions
3560
+ this.flushInteractions();
3561
+ }
3562
+ };
3563
+ this.apiClient = apiClient;
3564
+ this.options = {
3565
+ batchSize: 10,
3566
+ batchTimeout: 5000,
3567
+ trackClicks: true,
3568
+ trackScrolling: true,
3569
+ trackFormInteractions: true,
3570
+ trackErrors: true,
3571
+ trackPerformance: true,
3572
+ throttleScrollMs: 1000,
3573
+ maxScrollEvents: 50,
3574
+ debug: false,
3575
+ ...options
3576
+ };
3577
+ this.sessionId = this.generateSessionId();
3578
+ this.projectId = apiClient.getProjectId() || '';
3579
+ this.userFingerprint = this.generateUserFingerprint();
3580
+ this.sessionStartTime = Date.now();
3581
+ }
3582
+ // ==================== Public Methods ====================
3583
+ async startTracking() {
3584
+ if (this.isActive) {
3585
+ this.log('Analytics tracking already active');
3586
+ return;
3587
+ }
3588
+ try {
3589
+ // Create session with backend
3590
+ await this.createSession();
3591
+ // Setup event listeners
3592
+ this.setupEventListeners();
3593
+ // Track initial page view
3594
+ this.trackPageView();
3595
+ this.isActive = true;
3596
+ this.log('Analytics tracking started', { sessionId: this.sessionId });
3597
+ }
3598
+ catch (error) {
3599
+ console.error('Failed to start analytics tracking:', error);
3600
+ throw error;
3601
+ }
3602
+ }
3603
+ async stopTracking() {
3604
+ if (!this.isActive)
3605
+ return;
3606
+ try {
3607
+ // Flush any remaining interactions
3608
+ await this.flushInteractions();
3609
+ // Update session with end time
3610
+ await this.updateSessionEnd();
3611
+ // Remove event listeners
3612
+ this.removeEventListeners();
3613
+ // Clear timers
3614
+ if (this.batchTimer) {
3615
+ clearTimeout(this.batchTimer);
3616
+ this.batchTimer = null;
3617
+ }
3618
+ // Stop performance observer
3619
+ if (this.performanceObserver) {
3620
+ this.performanceObserver.disconnect();
3621
+ }
3622
+ this.isActive = false;
3623
+ this.log('Analytics tracking stopped');
3624
+ }
3625
+ catch (error) {
3626
+ console.error('Error stopping analytics tracking:', error);
3627
+ }
3628
+ }
3629
+ // ==================== Private Methods - Session Management ====================
3630
+ async createSession() {
3631
+ const sessionData = {
3632
+ sessionId: this.sessionId,
3633
+ projectId: this.projectId,
3634
+ userFingerprint: this.userFingerprint,
3635
+ // Technical context
3636
+ userAgent: navigator.userAgent,
3637
+ viewport: this.getViewportInfo(),
3638
+ browser: this.getBrowserInfo(),
3639
+ os: this.getOSInfo(),
3640
+ locale: navigator.language,
3641
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
3642
+ // Session data
3643
+ entryUrl: window.location.href,
3644
+ referrer: document.referrer || undefined
3645
+ };
3646
+ // Try to get geolocation (with permission)
3647
+ try {
3648
+ const geoData = await this.getGeolocation();
3649
+ if (geoData) {
3650
+ sessionData.latitude = geoData.latitude;
3651
+ sessionData.longitude = geoData.longitude;
3652
+ }
3653
+ }
3654
+ catch (error) {
3655
+ // Geolocation failed or denied - continue without it
3656
+ this.log('Geolocation not available:', error);
3657
+ }
3658
+ const response = await this.apiClient.post('/api/v1/analytics/sessions', sessionData);
3659
+ this.log('Session created:', response);
3660
+ }
3661
+ async updateSessionEnd() {
3662
+ const sessionDuration = Math.floor((Date.now() - this.sessionStartTime) / 1000);
3663
+ const updateData = {
3664
+ sessionEnd: new Date().toISOString(),
3665
+ durationSeconds: sessionDuration,
3666
+ pageViews: this.getPageViewCount(),
3667
+ hasFeedback: false, // Will be updated when feedback is created
3668
+ hasErrors: this.scrollEvents > 0 // Simple heuristic for now
3669
+ };
3670
+ try {
3671
+ await this.apiClient.put(`/api/v1/analytics/sessions/${this.sessionId}`, updateData);
3672
+ this.log('Session updated:', updateData);
3673
+ }
3674
+ catch (error) {
3675
+ console.error('Failed to update session:', error);
3676
+ }
3677
+ }
3678
+ // ==================== Private Methods - Event Setup ====================
3679
+ setupEventListeners() {
3680
+ // Page navigation
3681
+ this.currentPageUrl = window.location.href;
3682
+ window.addEventListener('beforeunload', this.handlePageUnload);
3683
+ window.addEventListener('pagehide', this.handlePageUnload);
3684
+ // Page visibility changes
3685
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
3686
+ if (this.options.trackClicks) {
3687
+ document.addEventListener('click', this.handleClick, { passive: true });
3688
+ }
3689
+ if (this.options.trackScrolling) {
3690
+ window.addEventListener('scroll', this.handleScroll, { passive: true });
3691
+ }
3692
+ if (this.options.trackFormInteractions) {
3693
+ document.addEventListener('focusin', this.handleFormFocus, { passive: true });
3694
+ document.addEventListener('change', this.handleFormChange, { passive: true });
3695
+ }
3696
+ if (this.options.trackErrors) {
3697
+ window.addEventListener('error', this.handleError);
3698
+ window.addEventListener('unhandledrejection', this.handleUnhandledRejection);
3699
+ }
3700
+ if (this.options.trackPerformance && 'PerformanceObserver' in window) {
3701
+ this.setupPerformanceTracking();
3702
+ }
3703
+ // Track SPA navigation
3704
+ this.setupSPATracking();
3705
+ }
3706
+ removeEventListeners() {
3707
+ window.removeEventListener('beforeunload', this.handlePageUnload);
3708
+ window.removeEventListener('pagehide', this.handlePageUnload);
3709
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
3710
+ document.removeEventListener('click', this.handleClick);
3711
+ window.removeEventListener('scroll', this.handleScroll);
3712
+ document.removeEventListener('focusin', this.handleFormFocus);
3713
+ document.removeEventListener('change', this.handleFormChange);
3714
+ window.removeEventListener('error', this.handleError);
3715
+ window.removeEventListener('unhandledrejection', this.handleUnhandledRejection);
3716
+ }
3717
+ // ==================== Private Methods - SPA & Performance ====================
3718
+ setupSPATracking() {
3719
+ // Track URL changes for SPAs
3720
+ let currentUrl = window.location.href;
3721
+ const checkUrlChange = () => {
3722
+ const newUrl = window.location.href;
3723
+ if (newUrl !== currentUrl) {
3724
+ currentUrl = newUrl;
3725
+ this.trackPageView();
3726
+ }
3727
+ };
3728
+ // Listen for pushState/replaceState
3729
+ const originalPushState = history.pushState;
3730
+ const originalReplaceState = history.replaceState;
3731
+ history.pushState = function (...args) {
3732
+ originalPushState.apply(this, args);
3733
+ setTimeout(checkUrlChange, 0);
3734
+ };
3735
+ history.replaceState = function (...args) {
3736
+ originalReplaceState.apply(this, args);
3737
+ setTimeout(checkUrlChange, 0);
3738
+ };
3739
+ // Listen for popstate
3740
+ window.addEventListener('popstate', checkUrlChange);
3741
+ }
3742
+ setupPerformanceTracking() {
3743
+ try {
3744
+ this.performanceObserver = new PerformanceObserver((list) => {
3745
+ const entries = list.getEntries();
3746
+ for (const entry of entries) {
3747
+ if (entry.entryType === 'navigation') {
3748
+ this.trackPageLoadPerformance(entry);
3749
+ }
3750
+ }
3751
+ });
3752
+ this.performanceObserver.observe({
3753
+ entryTypes: ['navigation', 'paint', 'largest-contentful-paint']
3754
+ });
3755
+ }
3756
+ catch (error) {
3757
+ this.log('Performance tracking setup failed:', error);
3758
+ }
3759
+ }
3760
+ trackPageView() {
3761
+ const loadTime = this.isFirstPageView ? this.getPageLoadTime() : undefined;
3762
+ const domReadyTime = this.isFirstPageView ? this.getDOMReadyTime() : undefined;
3763
+ const interaction = {
3764
+ pageUrl: window.location.href,
3765
+ pageTitle: document.title,
3766
+ eventType: 'page_view',
3767
+ loadTime,
3768
+ domReadyTime
3769
+ };
3770
+ this.queueInteraction(interaction);
3771
+ this.isFirstPageView = false;
3772
+ // Reset scroll tracking for new page
3773
+ this.scrollEvents = 0;
3774
+ }
3775
+ trackPageLoadPerformance(timing) {
3776
+ const loadTime = Math.round(timing.loadEventEnd - timing.loadEventStart);
3777
+ const domReadyTime = Math.round(timing.domContentLoadedEventEnd - timing.domContentLoadedEventStart);
3778
+ // Update the most recent page_view interaction with performance data
3779
+ const lastInteraction = this.interactionBuffer[this.interactionBuffer.length - 1];
3780
+ if (lastInteraction && lastInteraction.eventType === 'page_view') {
3781
+ lastInteraction.loadTime = loadTime;
3782
+ lastInteraction.domReadyTime = domReadyTime;
3783
+ }
3784
+ }
3785
+ // ==================== Private Methods - Batching ====================
3786
+ queueInteraction(interaction) {
3787
+ this.interactionBuffer.push(interaction);
3788
+ this.log('Interaction queued:', interaction);
3789
+ // Flush if buffer is full
3790
+ if (this.interactionBuffer.length >= this.options.batchSize) {
3791
+ this.flushInteractions();
3792
+ }
3793
+ else {
3794
+ // Set timeout for batch sending
3795
+ if (this.batchTimer) {
3796
+ clearTimeout(this.batchTimer);
3797
+ }
3798
+ this.batchTimer = window.setTimeout(() => {
3799
+ this.flushInteractions();
3800
+ }, this.options.batchTimeout);
3801
+ }
3802
+ }
3803
+ async flushInteractions() {
3804
+ if (this.interactionBuffer.length === 0)
3805
+ return;
3806
+ const interactions = [...this.interactionBuffer];
3807
+ this.interactionBuffer = [];
3808
+ if (this.batchTimer) {
3809
+ clearTimeout(this.batchTimer);
3810
+ this.batchTimer = null;
3811
+ }
3812
+ try {
3813
+ await this.apiClient.post(`/api/v1/analytics/sessions/${this.sessionId}/interactions/batch`, { interactions });
3814
+ this.log(`Flushed ${interactions.length} interactions`);
3815
+ }
3816
+ catch (error) {
3817
+ console.error('Failed to send interactions:', error);
3818
+ // Re-queue interactions on failure (with limit to avoid infinite growth)
3819
+ if (this.interactionBuffer.length < 50) {
3820
+ this.interactionBuffer.unshift(...interactions);
3821
+ }
3822
+ }
3823
+ }
3824
+ // ==================== Private Methods - Utilities ====================
3825
+ generateSessionId() {
3826
+ return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
3827
+ }
3828
+ generateUserFingerprint() {
3829
+ // Create anonymous fingerprint from browser characteristics
3830
+ const canvas = document.createElement('canvas');
3831
+ const ctx = canvas.getContext('2d');
3832
+ ctx?.fillText('CheckFlow', 2, 2);
3833
+ const fingerprint = [
3834
+ navigator.userAgent,
3835
+ navigator.language,
3836
+ screen.width + 'x' + screen.height,
3837
+ screen.colorDepth,
3838
+ new Date().getTimezoneOffset(),
3839
+ canvas.toDataURL()
3840
+ ].join('|');
3841
+ // Simple hash function
3842
+ let hash = 0;
3843
+ for (let i = 0; i < fingerprint.length; i++) {
3844
+ const char = fingerprint.charCodeAt(i);
3845
+ hash = ((hash << 5) - hash) + char;
3846
+ hash = hash & hash;
3847
+ }
3848
+ return Math.abs(hash).toString(36);
3849
+ }
3850
+ async getGeolocation() {
3851
+ return new Promise((resolve) => {
3852
+ if (!navigator.geolocation) {
3853
+ resolve(null);
3854
+ return;
3855
+ }
3856
+ navigator.geolocation.getCurrentPosition((position) => {
3857
+ resolve({
3858
+ latitude: position.coords.latitude,
3859
+ longitude: position.coords.longitude
3860
+ });
3861
+ }, () => resolve(null), { timeout: 5000, enableHighAccuracy: false });
3862
+ });
3863
+ }
3864
+ getViewportInfo() {
3865
+ const width = window.innerWidth;
3866
+ const height = window.innerHeight;
3867
+ let device = 'desktop';
3868
+ if (width <= 768) {
3869
+ device = 'mobile';
3870
+ }
3871
+ else if (width <= 1024) {
3872
+ device = 'tablet';
3873
+ }
3874
+ return { width, height, device };
3875
+ }
3876
+ getBrowserInfo() {
3877
+ const userAgent = navigator.userAgent;
3878
+ if (userAgent.includes('Chrome'))
3879
+ return 'Chrome';
3880
+ if (userAgent.includes('Firefox'))
3881
+ return 'Firefox';
3882
+ if (userAgent.includes('Safari'))
3883
+ return 'Safari';
3884
+ if (userAgent.includes('Edge'))
3885
+ return 'Edge';
3886
+ return 'Unknown';
3887
+ }
3888
+ getOSInfo() {
3889
+ const platform = navigator.platform;
3890
+ if (platform.includes('Win'))
3891
+ return 'Windows';
3892
+ if (platform.includes('Mac'))
3893
+ return 'macOS';
3894
+ if (platform.includes('Linux'))
3895
+ return 'Linux';
3896
+ if (/iPhone|iPad|iPod/.test(navigator.userAgent))
3897
+ return 'iOS';
3898
+ if (/Android/.test(navigator.userAgent))
3899
+ return 'Android';
3900
+ return 'Unknown';
3901
+ }
3902
+ getElementSelector(element) {
3903
+ // Generate a CSS selector for the element
3904
+ if (element.id) {
3905
+ return `#${element.id}`;
3906
+ }
3907
+ if (element.className) {
3908
+ const classes = element.className.trim().split(/\s+/);
3909
+ return `${element.tagName.toLowerCase()}.${classes.join('.')}`;
3910
+ }
3911
+ return element.tagName.toLowerCase();
3912
+ }
3913
+ getElementText(element) {
3914
+ // Get text content, but limit length for privacy/storage
3915
+ const text = element.textContent?.trim() || '';
3916
+ return text.substring(0, 100);
3917
+ }
3918
+ isFormElement(element) {
3919
+ const tagName = element.tagName.toLowerCase();
3920
+ return ['input', 'textarea', 'select'].includes(tagName);
3921
+ }
3922
+ calculateScrollDepth() {
3923
+ const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
3924
+ const documentHeight = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
3925
+ const windowHeight = window.innerHeight;
3926
+ const scrollable = documentHeight - windowHeight;
3927
+ if (scrollable <= 0)
3928
+ return 100;
3929
+ return Math.round((scrollTop / scrollable) * 100);
3930
+ }
3931
+ getPageLoadTime() {
3932
+ if (performance.timing) {
3933
+ return performance.timing.loadEventEnd - performance.timing.navigationStart;
3934
+ }
3935
+ return undefined;
3936
+ }
3937
+ getDOMReadyTime() {
3938
+ if (performance.timing) {
3939
+ return performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart;
3940
+ }
3941
+ return undefined;
3942
+ }
3943
+ getPageViewCount() {
3944
+ // Simple estimation based on interactions
3945
+ return this.interactionBuffer.filter(i => i.eventType === 'page_view').length + 1;
3946
+ }
3947
+ log(message, data) {
3948
+ if (this.options.debug) {
3949
+ console.log(`[CheckFlow Analytics] ${message}`, data);
3950
+ }
3951
+ }
3952
+ }
3953
+
3954
+ /**
3955
+ * CheckFlow SDK Main Class
3956
+ * Entry point for the CheckFlow feedback SDK
3957
+ */
3958
+ const DEFAULT_OPTIONS = {
3959
+ apiUrl: 'https://api.checkflow.space',
3960
+ showWidget: true,
3961
+ widgetPosition: 'bottom-right',
3962
+ widgetButtonText: 'Feedback',
3963
+ captureErrors: true,
3964
+ captureConsole: true,
3965
+ captureNetwork: true,
3966
+ capturePerformance: true,
3967
+ maxConsoleEntries: 100,
3968
+ maxNetworkEntries: 100,
3969
+ debug: false,
3970
+ locale: 'en',
3971
+ privacy: {
3972
+ enabled: true,
3973
+ autoMask: {
3974
+ emails: true,
3975
+ creditCards: true,
3976
+ phoneNumbers: true,
3977
+ passwords: true,
3978
+ },
3979
+ excludeSelectors: [],
3980
+ },
3981
+ // Analytics options
3982
+ enableAnalytics: true,
3983
+ analytics: {
3984
+ trackClicks: true,
3985
+ trackScrolling: true,
3986
+ trackFormInteractions: true,
3987
+ trackErrors: true,
3988
+ trackPerformance: true,
3989
+ batchSize: 10,
3990
+ batchTimeout: 5000,
3991
+ debug: false,
3992
+ },
3993
+ };
3994
+ class CheckFlow {
3995
+ constructor(apiKey, options = {}) {
3996
+ this.sessionRecording = null;
3997
+ this.widget = null;
3998
+ this.analyticsTracker = null;
3999
+ this.isInitialized = false;
4000
+ this.apiKey = apiKey;
4001
+ this.options = { ...DEFAULT_OPTIONS, ...options };
4002
+ // Initialize API client
4003
+ this.apiClient = new APIClient({
4004
+ apiUrl: this.options.apiUrl,
4005
+ apiKey: this.apiKey,
4006
+ projectId: this.options.projectId,
4007
+ debug: this.options.debug,
4008
+ });
4009
+ // Initialize context capture
4010
+ this.contextCapture = new ContextCapture({
4011
+ maxConsoleEntries: this.options.maxConsoleEntries,
4012
+ maxNetworkEntries: this.options.maxNetworkEntries,
4013
+ });
4014
+ // Initialize error capture
4015
+ this.errorCapture = new ErrorCapture(this.handleError.bind(this));
4016
+ // Initialize privacy masker
4017
+ this.privacyMasker = new PrivacyMasker({
4018
+ enabled: this.options.privacy?.enabled ?? true,
4019
+ autoMask: {
4020
+ emails: this.options.privacy?.autoMask?.emails ?? true,
4021
+ creditCards: this.options.privacy?.autoMask?.creditCards ?? true,
4022
+ phoneNumbers: this.options.privacy?.autoMask?.phoneNumbers ?? true,
4023
+ passwords: this.options.privacy?.autoMask?.passwords ?? true,
4024
+ socialSecurity: true,
4025
+ ipAddresses: false,
4026
+ customPatterns: [],
4027
+ },
4028
+ excludeSelectors: this.options.privacy?.excludeSelectors || [],
4029
+ includeSelectors: [],
4030
+ maskChar: '•',
4031
+ maskLength: 'preserve',
4032
+ fixedMaskLength: 8,
4033
+ });
4034
+ // Initialize analytics tracker
4035
+ if (this.options.enableAnalytics) {
4036
+ this.analyticsTracker = new AnalyticsTracker(this.apiClient, {
4037
+ batchSize: this.options.analytics?.batchSize || 10,
4038
+ batchTimeout: this.options.analytics?.batchTimeout || 5000,
4039
+ trackClicks: this.options.analytics?.trackClicks ?? true,
4040
+ trackScrolling: this.options.analytics?.trackScrolling ?? true,
4041
+ trackFormInteractions: this.options.analytics?.trackFormInteractions ?? true,
4042
+ trackErrors: this.options.analytics?.trackErrors ?? true,
4043
+ trackPerformance: this.options.analytics?.trackPerformance ?? true,
4044
+ throttleScrollMs: 1000,
4045
+ maxScrollEvents: 50,
4046
+ debug: this.options.analytics?.debug || this.options.debug || false,
4047
+ });
4048
+ }
4049
+ // Store as singleton
4050
+ CheckFlow.instance = this;
4051
+ }
4052
+ /**
4053
+ * Initialize the SDK
4054
+ */
4055
+ init() {
4056
+ if (this.isInitialized) {
4057
+ this.log('SDK already initialized');
4058
+ return this;
4059
+ }
4060
+ this.log('Initializing CheckFlow SDK...');
4061
+ // Start context capture
4062
+ if (this.options.captureConsole || this.options.captureNetwork) {
4063
+ this.contextCapture.startCapture();
4064
+ }
4065
+ // Start error capture
4066
+ if (this.options.captureErrors) {
4067
+ this.errorCapture.start();
4068
+ }
4069
+ // Start session recording
4070
+ if (this.options.captureSessionRecording !== false) {
4071
+ this.sessionRecording = new SessionRecording();
4072
+ this.sessionRecording.start();
4073
+ this.log('Session recording started');
4074
+ }
4075
+ // Start analytics tracking
4076
+ if (this.analyticsTracker) {
4077
+ try {
4078
+ this.analyticsTracker.startTracking();
4079
+ this.log('Analytics tracking started');
4080
+ }
4081
+ catch (error) {
4082
+ this.log('Failed to start analytics tracking:', error);
4083
+ }
4084
+ }
4085
+ // Show widget
4086
+ if (this.options.showWidget) {
4087
+ this.showWidget();
4088
+ }
4089
+ this.isInitialized = true;
4090
+ this.log('SDK initialized successfully');
4091
+ return this;
4092
+ }
4093
+ /**
4094
+ * Show the feedback widget
4095
+ */
4096
+ showWidget() {
4097
+ if (this.widget) {
4098
+ return;
4099
+ }
4100
+ this.widget = new FeedbackWidget({
4101
+ position: this.options.widgetPosition,
4102
+ buttonText: this.options.widgetButtonText,
4103
+ locale: this.options.locale,
4104
+ translations: this.options.translations,
4105
+ onSubmit: this.handleWidgetSubmit.bind(this),
4106
+ onClose: () => this.log('Widget closed'),
4107
+ });
4108
+ this.widget.mount();
4109
+ this.log('Widget mounted');
4110
+ }
4111
+ /**
4112
+ * Hide the feedback widget
4113
+ */
4114
+ hideWidget() {
4115
+ if (this.widget) {
4116
+ this.widget.unmount();
4117
+ this.widget = null;
4118
+ this.log('Widget unmounted');
4119
+ }
4120
+ }
4121
+ /**
4122
+ * Open the feedback form
4123
+ */
4124
+ open() {
4125
+ if (!this.widget) {
4126
+ this.showWidget();
4127
+ }
4128
+ this.widget?.open();
4129
+ }
4130
+ /**
4131
+ * Close the feedback form
4132
+ */
4133
+ close() {
4134
+ this.widget?.close();
4135
+ }
4136
+ /**
4137
+ * Capture current page context
4138
+ */
4139
+ async capture(options = {}) {
4140
+ this.log('Capturing context...');
4141
+ const result = await this.contextCapture.capture({
4142
+ includeConsole: options.includeConsole ?? this.options.captureConsole,
4143
+ includeNetwork: options.includeNetwork ?? this.options.captureNetwork,
4144
+ includePerformance: options.includePerformance ?? this.options.capturePerformance,
4145
+ ...options,
4146
+ });
4147
+ this.log('Context captured', result);
4148
+ return result;
4149
+ }
4150
+ /**
4151
+ * Submit feedback
4152
+ */
4153
+ async submitFeedback(feedback) {
4154
+ this.log('Submitting feedback...', feedback);
4155
+ // Call beforeSubmit hook
4156
+ if (this.options.beforeSubmit) {
4157
+ const result = this.options.beforeSubmit(feedback);
4158
+ if (result === false) {
4159
+ this.log('Submission cancelled by beforeSubmit hook');
4160
+ return { success: false, error: 'Submission cancelled' };
4161
+ }
4162
+ feedback = result;
4163
+ }
4164
+ // Capture context if requested
4165
+ let captureResult;
4166
+ if (feedback.includeScreenshot || feedback.includeConsoleLogs || feedback.includeNetworkLogs) {
4167
+ captureResult = await this.capture({
4168
+ includeConsole: feedback.includeConsoleLogs,
4169
+ includeNetwork: feedback.includeNetworkLogs,
4170
+ });
4171
+ }
4172
+ // Merge user info
4173
+ const user = { ...this.options.user, ...feedback.user };
4174
+ // Get session recording if available
4175
+ let sessionRecording;
4176
+ if (this.sessionRecording && feedback.includeSessionRecording !== false) {
4177
+ const recordingData = this.sessionRecording.getRecordingData();
4178
+ if (recordingData && recordingData.events.length > 0) {
4179
+ sessionRecording = {
4180
+ events: recordingData.events,
4181
+ sessionId: recordingData.sessionId,
4182
+ };
4183
+ this.log('Including session recording', { eventCount: recordingData.events.length });
4184
+ }
4185
+ }
4186
+ // Submit to API
4187
+ const result = await this.apiClient.submitFeedback(feedback, captureResult, user, sessionRecording);
4188
+ // Call onSubmit hook
4189
+ if (this.options.onSubmit) {
4190
+ this.options.onSubmit(result);
4191
+ }
4192
+ this.log('Feedback submitted', result);
4193
+ return result;
4194
+ }
4195
+ /**
4196
+ * Capture and report an error
4197
+ */
4198
+ captureException(error, context) {
4199
+ this.errorCapture.captureException(error, context);
4200
+ }
4201
+ /**
4202
+ * Set user information
4203
+ */
4204
+ setUser(user) {
4205
+ this.options.user = { ...this.options.user, ...user };
4206
+ this.log('User set', user);
4207
+ }
4208
+ /**
4209
+ * Clear user information
4210
+ */
4211
+ clearUser() {
4212
+ this.options.user = undefined;
4213
+ this.log('User cleared');
4214
+ }
4215
+ /**
4216
+ * Add custom metadata
4217
+ */
4218
+ setMetadata(metadata) {
4219
+ this.options.metadata = { ...this.options.metadata, ...metadata };
4220
+ }
4221
+ /**
4222
+ * Get console logs
4223
+ */
4224
+ getConsoleLogs() {
4225
+ return this.contextCapture.getConsoleLogs();
4226
+ }
4227
+ /**
4228
+ * Get network logs
4229
+ */
4230
+ getNetworkLogs() {
4231
+ return this.contextCapture.getNetworkLogs();
4232
+ }
4233
+ /**
4234
+ * Get captured errors
4235
+ */
4236
+ getErrors() {
4237
+ return this.errorCapture.getErrors();
4238
+ }
4239
+ /**
4240
+ * Configure privacy settings
4241
+ */
4242
+ configurePrivacy(config) {
4243
+ this.privacyMasker.configure({
4244
+ enabled: config.enabled,
4245
+ autoMask: config.autoMask ? {
4246
+ emails: config.autoMask.emails ?? true,
4247
+ creditCards: config.autoMask.creditCards ?? true,
4248
+ phoneNumbers: config.autoMask.phoneNumbers ?? true,
4249
+ passwords: config.autoMask.passwords ?? true,
4250
+ socialSecurity: true,
4251
+ ipAddresses: false,
4252
+ customPatterns: [],
4253
+ } : undefined,
4254
+ excludeSelectors: config.excludeSelectors,
4255
+ });
4256
+ this.log('Privacy configured', config);
4257
+ }
4258
+ /**
4259
+ * Mask text using privacy rules
4260
+ */
4261
+ maskText(text) {
4262
+ return this.privacyMasker.maskText(text);
4263
+ }
4264
+ /**
4265
+ * Get privacy masker instance
4266
+ */
4267
+ getPrivacyMasker() {
4268
+ return this.privacyMasker;
4269
+ }
4270
+ /**
4271
+ * Destroy the SDK instance
4272
+ */
4273
+ destroy() {
4274
+ this.log('Destroying SDK...');
4275
+ this.hideWidget();
4276
+ this.contextCapture.stopCapture();
4277
+ this.errorCapture.stop();
4278
+ this.isInitialized = false;
4279
+ if (CheckFlow.instance === this) {
4280
+ CheckFlow.instance = null;
4281
+ }
4282
+ this.log('SDK destroyed');
4283
+ }
4284
+ /**
4285
+ * Get the singleton instance
4286
+ */
4287
+ static getInstance() {
4288
+ return CheckFlow.instance;
4289
+ }
4290
+ // Private methods
4291
+ async handleWidgetSubmit(data) {
4292
+ // Prepare feedback with user info from widget form
4293
+ const feedback = {
4294
+ title: `Rapport de ${data.name}`,
4295
+ description: data.description,
4296
+ type: data.type,
4297
+ priority: data.priority,
4298
+ includeScreenshot: !!data.screenshot,
4299
+ includeConsoleLogs: data.includeConsole,
4300
+ includeNetworkLogs: data.includeNetwork,
4301
+ user: {
4302
+ name: data.name,
4303
+ email: data.email,
4304
+ },
4305
+ };
4306
+ // If screenshot provided directly from widget, add to capture result
4307
+ let captureResult;
4308
+ if (data.screenshot) {
4309
+ captureResult = await this.contextCapture.capture({
4310
+ includeConsole: data.includeConsole,
4311
+ includeNetwork: data.includeNetwork,
4312
+ includePerformance: true,
4313
+ });
4314
+ captureResult.screenshot = data.screenshot;
4315
+ }
4316
+ // Submit with annotations
4317
+ const result = await this.apiClient.submitFeedback(feedback, captureResult, { name: data.name, email: data.email }, undefined, // session recording
4318
+ data.annotations);
4319
+ if (!result.success) {
4320
+ throw new Error(result.error || 'Failed to submit feedback');
4321
+ }
4322
+ }
4323
+ handleError(error) {
4324
+ this.log('Error captured', error);
4325
+ // Report to backend if configured
4326
+ if (this.options.captureErrors) {
4327
+ this.apiClient.reportError({
4328
+ message: error.message,
4329
+ stack: error.stack,
4330
+ type: error.type,
4331
+ filename: error.filename,
4332
+ lineno: error.lineno,
4333
+ colno: error.colno,
4334
+ context: error.context,
4335
+ }).catch((e) => this.log('Failed to report error', e));
4336
+ }
4337
+ // Call user error handler
4338
+ if (this.options.onError) {
4339
+ this.options.onError(new Error(error.message));
4340
+ }
4341
+ }
4342
+ log(...args) {
4343
+ if (this.options.debug) {
4344
+ console.log('[CheckFlow]', ...args);
4345
+ }
4346
+ }
4347
+ }
4348
+ CheckFlow.instance = null;
4349
+ /**
4350
+ * Factory function for creating CheckFlow instance
4351
+ */
4352
+ function createCheckFlow(apiKey, options) {
4353
+ return new CheckFlow(apiKey, options).init();
4354
+ }
4355
+
4356
+ const CheckFlowContext = react.createContext(null);
4357
+ function CheckFlowProvider({ apiKey, options = {}, children }) {
4358
+ const [checkflow, setCheckflow] = react.useState(null);
4359
+ const [isReady, setIsReady] = react.useState(false);
4360
+ react.useEffect(() => {
4361
+ const instance = createCheckFlow(apiKey, options);
4362
+ setCheckflow(instance);
4363
+ setIsReady(true);
4364
+ return () => {
4365
+ instance.destroy();
4366
+ };
4367
+ }, [apiKey]);
4368
+ const capture = react.useCallback(async () => {
4369
+ if (!checkflow)
4370
+ return null;
4371
+ return checkflow.capture();
4372
+ }, [checkflow]);
4373
+ const submitFeedback = react.useCallback(async (feedback) => {
4374
+ if (!checkflow) {
4375
+ return { success: false, error: 'CheckFlow not initialized' };
4376
+ }
4377
+ return checkflow.submitFeedback(feedback);
4378
+ }, [checkflow]);
4379
+ const openWidget = react.useCallback(() => {
4380
+ checkflow?.open();
4381
+ }, [checkflow]);
4382
+ const closeWidget = react.useCallback(() => {
4383
+ checkflow?.close();
4384
+ }, [checkflow]);
4385
+ const setUser = react.useCallback((user) => {
4386
+ checkflow?.setUser(user);
4387
+ }, [checkflow]);
4388
+ const clearUser = react.useCallback(() => {
4389
+ checkflow?.clearUser();
4390
+ }, [checkflow]);
4391
+ const value = react.useMemo(() => ({
4392
+ checkflow,
4393
+ isReady,
4394
+ capture,
4395
+ submitFeedback,
4396
+ openWidget,
4397
+ closeWidget,
4398
+ setUser,
4399
+ clearUser,
4400
+ }), [checkflow, isReady, capture, submitFeedback, openWidget, closeWidget, setUser, clearUser]);
4401
+ return (jsxRuntime.jsx(CheckFlowContext.Provider, { value: value, children: children }));
4402
+ }
4403
+ // ==================== Hook ====================
4404
+ function useCheckFlow() {
4405
+ const context = react.useContext(CheckFlowContext);
4406
+ if (!context) {
4407
+ throw new Error('useCheckFlow must be used within a CheckFlowProvider');
4408
+ }
4409
+ return context;
4410
+ }
4411
+ function useFeedbackForm(options = {}) {
4412
+ const { submitFeedback, capture } = useCheckFlow();
4413
+ const [isSubmitting, setIsSubmitting] = react.useState(false);
4414
+ const [error, setError] = react.useState(null);
4415
+ const [screenshot, setScreenshot] = react.useState(null);
4416
+ const [formState, setFormState] = react.useState({
4417
+ title: '',
4418
+ description: '',
4419
+ type: options.defaultType || 'bug',
4420
+ priority: options.defaultPriority || 'medium',
4421
+ includeScreenshot: true,
4422
+ includeConsoleLogs: true,
4423
+ includeNetworkLogs: true,
4424
+ });
4425
+ const updateField = react.useCallback((field, value) => {
4426
+ setFormState(prev => ({ ...prev, [field]: value }));
4427
+ }, []);
4428
+ const captureScreenshot = react.useCallback(async () => {
4429
+ const result = await capture();
4430
+ if (result?.screenshot) {
4431
+ setScreenshot(result.screenshot);
4432
+ }
4433
+ return result;
4434
+ }, [capture]);
4435
+ const submit = react.useCallback(async () => {
4436
+ if (!formState.title.trim()) {
4437
+ setError('Title is required');
4438
+ return null;
4439
+ }
4440
+ setIsSubmitting(true);
4441
+ setError(null);
4442
+ try {
4443
+ const result = await submitFeedback({
4444
+ ...formState,
4445
+ });
4446
+ if (result.success) {
4447
+ options.onSuccess?.(result);
4448
+ // Reset form
4449
+ setFormState({
4450
+ title: '',
4451
+ description: '',
4452
+ type: options.defaultType || 'bug',
4453
+ priority: options.defaultPriority || 'medium',
4454
+ includeScreenshot: true,
4455
+ includeConsoleLogs: true,
4456
+ includeNetworkLogs: true,
4457
+ });
4458
+ setScreenshot(null);
4459
+ }
4460
+ else {
4461
+ setError(result.error || 'Failed to submit feedback');
4462
+ options.onError?.(new Error(result.error));
4463
+ }
4464
+ return result;
4465
+ }
4466
+ catch (err) {
4467
+ const errorMessage = err.message || 'An error occurred';
4468
+ setError(errorMessage);
4469
+ options.onError?.(err);
4470
+ return null;
4471
+ }
4472
+ finally {
4473
+ setIsSubmitting(false);
4474
+ }
4475
+ }, [formState, submitFeedback, options]);
4476
+ const reset = react.useCallback(() => {
4477
+ setFormState({
4478
+ title: '',
4479
+ description: '',
4480
+ type: options.defaultType || 'bug',
4481
+ priority: options.defaultPriority || 'medium',
4482
+ includeScreenshot: true,
4483
+ includeConsoleLogs: true,
4484
+ includeNetworkLogs: true,
4485
+ });
4486
+ setScreenshot(null);
4487
+ setError(null);
4488
+ }, [options.defaultType, options.defaultPriority]);
4489
+ return {
4490
+ formState,
4491
+ updateField,
4492
+ screenshot,
4493
+ captureScreenshot,
4494
+ submit,
4495
+ reset,
4496
+ isSubmitting,
4497
+ error,
4498
+ };
4499
+ }
4500
+ class CheckFlowErrorBoundary extends react.Component {
4501
+ constructor(props) {
4502
+ super(props);
4503
+ this.reset = () => {
4504
+ this.setState({ hasError: false, error: null });
4505
+ };
4506
+ this.state = { hasError: false, error: null };
4507
+ }
4508
+ static getDerivedStateFromError(error) {
4509
+ return { hasError: true, error };
4510
+ }
4511
+ componentDidCatch(error, errorInfo) {
4512
+ // Call user handler
4513
+ this.props.onError?.(error, errorInfo);
4514
+ // Report to CheckFlow
4515
+ if (this.props.reportToCheckFlow !== false) {
4516
+ const checkflow = CheckFlow.getInstance();
4517
+ if (checkflow) {
4518
+ checkflow.captureException(error, {
4519
+ componentStack: errorInfo.componentStack,
4520
+ });
4521
+ }
4522
+ }
4523
+ }
4524
+ render() {
4525
+ if (this.state.hasError && this.state.error) {
4526
+ const { fallback } = this.props;
4527
+ if (typeof fallback === 'function') {
4528
+ return fallback(this.state.error, this.reset);
4529
+ }
4530
+ if (fallback) {
4531
+ return fallback;
4532
+ }
4533
+ // Default fallback
4534
+ return (jsxRuntime.jsxs("div", { style: {
4535
+ padding: '20px',
4536
+ background: '#fef2f2',
4537
+ border: '1px solid #fecaca',
4538
+ borderRadius: '8px',
4539
+ color: '#991b1b',
4540
+ }, children: [jsxRuntime.jsx("h3", { style: { margin: '0 0 8px' }, children: "Something went wrong" }), jsxRuntime.jsx("p", { style: { margin: '0 0 12px', fontSize: '14px' }, children: this.state.error.message }), jsxRuntime.jsx("button", { onClick: this.reset, style: {
4541
+ padding: '8px 16px',
4542
+ background: '#dc2626',
4543
+ color: 'white',
4544
+ border: 'none',
4545
+ borderRadius: '4px',
4546
+ cursor: 'pointer',
4547
+ }, children: "Try again" })] }));
4548
+ }
4549
+ return this.props.children;
4550
+ }
4551
+ }
4552
+ // ==================== HOC ====================
4553
+ function withCheckFlow(WrappedComponent) {
4554
+ return function WithCheckFlowComponent(props) {
4555
+ const checkflow = useCheckFlow();
4556
+ return jsxRuntime.jsx(WrappedComponent, { ...props, checkflow: checkflow });
4557
+ };
4558
+ }
4559
+ function withErrorBoundary(WrappedComponent, errorBoundaryProps) {
4560
+ return function WithErrorBoundaryComponent(props) {
4561
+ return (jsxRuntime.jsx(CheckFlowErrorBoundary, { ...errorBoundaryProps, children: jsxRuntime.jsx(WrappedComponent, { ...props }) }));
4562
+ };
4563
+ }
4564
+ function FeedbackButton({ children, className, style }) {
4565
+ const { openWidget } = useCheckFlow();
4566
+ return (jsxRuntime.jsx("button", { onClick: openWidget, className: className, style: style, children: children || 'Send Feedback' }));
4567
+ }
4568
+
4569
+ /**
4570
+ * CheckFlow SDK
4571
+ * User feedback collection with automatic context capture
4572
+ *
4573
+ * @packageDocumentation
4574
+ */
4575
+ // Main exports
4576
+ // Version
4577
+ const VERSION = '1.0.0';
4578
+
4579
+ exports.APIClient = APIClient;
4580
+ exports.AnnotationEditor = AnnotationEditor;
4581
+ exports.AnnotationToolbar = AnnotationToolbar;
4582
+ exports.BLUR_STYLE = BLUR_STYLE;
4583
+ exports.COLOR_PALETTE = COLOR_PALETTE;
4584
+ exports.CheckFlow = CheckFlow;
4585
+ exports.CheckFlowErrorBoundary = CheckFlowErrorBoundary;
4586
+ exports.CheckFlowProvider = CheckFlowProvider;
4587
+ exports.ContextCapture = ContextCapture;
4588
+ exports.DEFAULT_PRIVACY_CONFIG = DEFAULT_PRIVACY_CONFIG;
4589
+ exports.DEFAULT_STYLE = DEFAULT_STYLE;
4590
+ exports.DEFAULT_TRANSLATIONS = DEFAULT_TRANSLATIONS;
4591
+ exports.ErrorCapture = ErrorCapture;
4592
+ exports.FeedbackButton = FeedbackButton;
4593
+ exports.FeedbackWidget = FeedbackWidget;
4594
+ exports.HIGHLIGHT_STYLE = HIGHLIGHT_STYLE;
4595
+ exports.PRIVACY_PATTERNS = PATTERNS;
4596
+ exports.PrivacyDetector = PrivacyDetector;
4597
+ exports.PrivacyMasker = PrivacyMasker;
4598
+ exports.STROKE_WIDTHS = STROKE_WIDTHS;
4599
+ exports.SessionRecording = SessionRecording;
4600
+ exports.TRANSLATIONS = TRANSLATIONS;
4601
+ exports.VERSION = VERSION;
4602
+ exports.capturePageContext = capturePageContext;
4603
+ exports.capturePerformance = capturePerformance;
4604
+ exports.captureScreenshot = captureScreenshot;
4605
+ exports.createCheckFlow = createCheckFlow;
4606
+ exports.default = CheckFlow;
4607
+ exports.injectAnnotationStyles = injectAnnotationStyles;
4608
+ exports.removeAnnotationStyles = removeAnnotationStyles;
4609
+ exports.useCheckFlow = useCheckFlow;
4610
+ exports.useFeedbackForm = useFeedbackForm;
4611
+ exports.withCheckFlow = withCheckFlow;
4612
+ exports.withErrorBoundary = withErrorBoundary;
4613
+ //# sourceMappingURL=index.js.map