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