UNPKG

@triagly/sdk

Version:

JavaScript SDK for Triagly - Turn user feedback into GitHub issues instantly

1,341 lines (1,291 loc) 46.7 kB
// Feedback Widget UI class FeedbackWidget { constructor(config) { this.container = null; this.isOpen = false; this.previouslyFocusedElement = null; this.focusableElements = []; this.config = config; } /** * Initialize the widget */ init() { this.createButton(); this.injectStyles(); } /** * Create the feedback button */ createButton() { const button = document.createElement('button'); button.id = 'triagly-button'; button.className = 'triagly-button'; // Button shape const shape = this.config.buttonShape || 'rounded'; button.classList.add(`triagly-shape-${shape}`); // Button orientation const orientation = this.config.orientation || 'horizontal'; button.classList.add(`triagly-orientation-${orientation}`); // Handle button text based on shape const fullText = this.config.buttonText || '🐛 Feedback'; if (shape === 'circular') { button.innerHTML = '🐛'; button.setAttribute('aria-label', fullText); } else if (shape === 'expandable') { // Expandable starts with emoji, expands to full text on hover button.innerHTML = '<span class="triagly-btn-icon">🐛</span><span class="triagly-btn-text"> Feedback</span>'; button.setAttribute('aria-label', fullText); // Store custom text if provided if (this.config.buttonText) { const textSpan = button.querySelector('.triagly-btn-text'); if (textSpan) { textSpan.textContent = ' ' + this.config.buttonText.replace('🐛', '').trim(); } } } else { button.innerHTML = fullText; } button.onclick = () => this.toggle(); // Position button const position = this.config.position || 'bottom-right'; button.classList.add(`triagly-${position}`); // For expandable buttons, set expansion direction based on position if (shape === 'expandable') { if (position.includes('right')) { button.classList.add('triagly-expand-left'); } else if (position.includes('left')) { button.classList.add('triagly-expand-right'); } } // Apply custom offsets if provided if (this.config.offsetX) { if (position.includes('right')) { button.style.right = this.config.offsetX; } else if (position.includes('left')) { button.style.left = this.config.offsetX; } } if (this.config.offsetY) { if (position.includes('top')) { button.style.top = this.config.offsetY; } else if (position.includes('bottom')) { button.style.bottom = this.config.offsetY; } } document.body.appendChild(button); } /** * Toggle widget visibility */ toggle() { if (this.isOpen) { this.close(); } else { this.open(); } } /** * Open the widget */ open() { if (this.isOpen) return; // Store currently focused element to restore later this.previouslyFocusedElement = document.activeElement; this.container = this.createContainer(); document.body.appendChild(this.container); this.isOpen = true; // Call onOpen callback if (this.config.onOpen) { this.config.onOpen(); } // Set up keyboard and focus after DOM is ready setTimeout(() => { // Set up keyboard event listener this.setupKeyboardEvents(); // Set up focus trap this.setupFocusTrap(); // Focus on title field const titleInput = this.container?.querySelector('input[type="text"]'); titleInput?.focus(); }, 0); } /** * Close the widget */ close(reason) { if (!this.isOpen || !this.container) return; // Clean up tab handler (must match capture phase used when adding) const tabHandler = this.container._tabHandler; if (tabHandler) { document.removeEventListener('keydown', tabHandler, true); } this.container.remove(); this.container = null; this.isOpen = false; // Restore focus to previously focused element if (this.previouslyFocusedElement) { this.previouslyFocusedElement.focus(); this.previouslyFocusedElement = null; } // Call specific callbacks based on reason if (reason === 'cancel' && this.config.onCancel) { this.config.onCancel(); } else if (reason === 'dismiss' && this.config.onDismiss) { this.config.onDismiss(); } else if (reason === 'overlay' && this.config.onOverlayClick) { this.config.onOverlayClick(); } // Always call general onClose callback (backward compatible) if (this.config.onClose) { this.config.onClose(); } } /** * Create the widget container */ createContainer() { const overlay = document.createElement('div'); overlay.className = 'triagly-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-labelledby', 'triagly-modal-title'); overlay.onclick = (e) => { if (e.target === overlay) this.close('overlay'); }; const modal = document.createElement('div'); modal.className = 'triagly-modal'; modal.setAttribute('role', 'document'); const header = document.createElement('div'); header.className = 'triagly-header'; header.innerHTML = ` <h3 id="triagly-modal-title">Send Feedback</h3> <button type="button" class="triagly-close" aria-label="Close feedback form">×</button> `; const closeBtn = header.querySelector('.triagly-close'); closeBtn?.addEventListener('click', () => this.close('dismiss')); const form = document.createElement('form'); form.className = 'triagly-form'; form.innerHTML = ` <div class="triagly-field"> <label for="triagly-title">Title (optional)</label> <input type="text" id="triagly-title" placeholder="Brief summary of your feedback" /> </div> <div class="triagly-field"> <label for="triagly-description">Description *</label> <textarea id="triagly-description" required rows="5" placeholder="${this.config.placeholderText || 'Describe what happened...'}" ></textarea> </div> <div class="triagly-field"> <label for="triagly-email">Email (optional)</label> <input type="email" id="triagly-email" placeholder="your@email.com" /> </div> <div class="triagly-field triagly-checkbox"> <label> <input type="checkbox" id="triagly-screenshot" checked /> <span>Include screenshot</span> </label> </div> ${this.config.turnstileSiteKey ? ` <div class="triagly-field triagly-turnstile"> <div class="cf-turnstile" data-sitekey="${this.config.turnstileSiteKey}" data-theme="light"></div> </div> ` : ''} <div class="triagly-actions"> <button type="button" class="triagly-btn-secondary" id="triagly-cancel" aria-label="Cancel and close feedback form"> Cancel </button> <button type="submit" class="triagly-btn-primary" aria-label="Submit feedback"> Send Feedback </button> </div> <div class="triagly-status" id="triagly-status" role="status" aria-live="polite"></div> `; const cancelBtn = form.querySelector('#triagly-cancel'); cancelBtn?.addEventListener('click', () => this.close('cancel')); form.onsubmit = (e) => { e.preventDefault(); this.handleSubmit(form); }; modal.appendChild(header); modal.appendChild(form); overlay.appendChild(modal); // Render Turnstile widget if available if (this.config.turnstileSiteKey) { setTimeout(() => { this.renderTurnstileWidget(form); }, 100); } return overlay; } /** * Render Cloudflare Turnstile widget */ renderTurnstileWidget(form) { const turnstileContainer = form.querySelector('.cf-turnstile'); if (!turnstileContainer) return; // Check if Turnstile script is loaded if (!window.turnstile) { console.warn('Triagly: Turnstile script not loaded. Please include: <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>'); return; } try { const widgetId = window.turnstile.render(turnstileContainer, { sitekey: this.config.turnstileSiteKey, theme: this.config.theme === 'dark' ? 'dark' : 'light', callback: (token) => { // Store token in a data attribute for easy retrieval turnstileContainer.setAttribute('data-turnstile-response', token); turnstileContainer.setAttribute('data-widget-id', widgetId); }, 'error-callback': () => { console.error('Triagly: Turnstile widget error'); }, 'expired-callback': () => { // Clear stored token when it expires turnstileContainer.removeAttribute('data-turnstile-response'); }, }); turnstileContainer.setAttribute('data-widget-id', widgetId); } catch (error) { console.error('Triagly: Failed to render Turnstile widget:', error); } } /** * Handle form submission */ async handleSubmit(form) { const titleInput = form.querySelector('#triagly-title'); const descInput = form.querySelector('#triagly-description'); const emailInput = form.querySelector('#triagly-email'); const screenshotCheckbox = form.querySelector('#triagly-screenshot'); const statusDiv = form.querySelector('#triagly-status'); const submitBtn = form.querySelector('button[type="submit"]'); const turnstileContainer = form.querySelector('.cf-turnstile'); // Disable form submitBtn.disabled = true; submitBtn.textContent = 'Sending...'; try { // Get Turnstile token if widget is present let turnstileToken; if (turnstileContainer) { turnstileToken = turnstileContainer.getAttribute('data-turnstile-response') || undefined; } const data = { title: titleInput.value.trim() || undefined, description: descInput.value.trim(), reporterEmail: emailInput.value.trim() || undefined, includeScreenshot: screenshotCheckbox.checked, turnstileToken, }; // Create a promise that waits for actual submission result const submissionPromise = new Promise((resolve, reject) => { const handleSuccess = () => { document.removeEventListener('triagly:success', handleSuccess); document.removeEventListener('triagly:error', handleError); resolve(); }; const handleError = (e) => { document.removeEventListener('triagly:success', handleSuccess); document.removeEventListener('triagly:error', handleError); reject(e.detail); }; document.addEventListener('triagly:success', handleSuccess, { once: true }); document.addEventListener('triagly:error', handleError, { once: true }); // Set a timeout in case events don't fire setTimeout(() => { document.removeEventListener('triagly:success', handleSuccess); document.removeEventListener('triagly:error', handleError); reject(new Error('Submission timeout')); }, 30000); // 30 second timeout }); // Dispatch custom event for parent to handle const event = new CustomEvent('triagly:submit', { detail: data, bubbles: true, }); document.dispatchEvent(event); // Wait for actual submission result await submissionPromise; // Show success statusDiv.className = 'triagly-status triagly-success'; statusDiv.textContent = this.config.successMessage || 'Feedback sent successfully!'; // Close after delay setTimeout(() => { this.close(); }, 2000); } catch (error) { // Show error with actual error message statusDiv.className = 'triagly-status triagly-error'; const errorMessage = error instanceof Error ? error.message : (this.config.errorMessage || 'Failed to send feedback. Please try again.'); statusDiv.textContent = errorMessage; // Re-enable form submitBtn.disabled = false; submitBtn.textContent = 'Send Feedback'; } } /** * Inject widget styles */ injectStyles() { if (document.getElementById('triagly-styles')) return; const style = document.createElement('style'); style.id = 'triagly-styles'; style.textContent = ` .triagly-button { position: fixed; z-index: 999999; padding: 12px 20px; background: var(--triagly-button-bg, #6366f1); color: var(--triagly-button-text, #ffffff); border: none; border-radius: var(--triagly-button-radius, 8px); font-size: 14px; font-weight: 500; cursor: pointer; box-shadow: var(--triagly-button-shadow, 0 4px 12px rgba(99, 102, 241, 0.3)); transition: all 0.2s; } .triagly-button:hover { background: var(--triagly-button-bg-hover, #4f46e5); transform: translateY(-2px); box-shadow: var(--triagly-button-shadow-hover, 0 6px 16px rgba(99, 102, 241, 0.4)); } /* Prevent expandable buttons from shifting on hover */ .triagly-button.triagly-shape-expandable:hover { transform: translateY(0) !important; } .triagly-bottom-right { bottom: 20px; right: 20px; } .triagly-bottom-left { bottom: 20px; left: 20px; } .triagly-top-right { top: 20px; right: 20px; } .triagly-top-left { top: 20px; left: 20px; } /* Edge-aligned positions (0 offset from edges) */ .triagly-edge-bottom-right { bottom: 0; right: 0; } .triagly-edge-bottom-left { bottom: 0; left: 0; } .triagly-edge-top-right { top: 0; right: 0; } .triagly-edge-top-left { top: 0; left: 0; } .triagly-edge-right { top: 50%; right: 0; transform: translateY(-50%); } .triagly-edge-left { top: 50%; left: 0; transform: translateY(-50%); } .triagly-edge-top { top: 0; left: 50%; transform: translateX(-50%); } .triagly-edge-bottom { bottom: 0; left: 50%; transform: translateX(-50%); } /* Button shapes */ .triagly-shape-rounded { border-radius: var(--triagly-button-radius, 8px); } .triagly-shape-circular { border-radius: 50%; width: 60px; height: 60px; padding: 0; font-size: 24px; display: flex; align-items: center; justify-content: center; } .triagly-shape-square { border-radius: 0; } .triagly-shape-pill { border-radius: 30px; } .triagly-shape-expandable { border-radius: 50%; width: 60px; height: 60px; min-width: 60px; padding: 0; font-size: 24px; display: flex; align-items: center; justify-content: center; overflow: hidden; transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), border-radius 0.3s cubic-bezier(0.4, 0, 0.2, 1), padding 0.3s cubic-bezier(0.4, 0, 0.2, 1), background 0.2s, box-shadow 0.2s; white-space: nowrap; } /* Expansion direction - expands left for right-positioned buttons */ .triagly-shape-expandable.triagly-expand-left { flex-direction: row-reverse; } /* Expansion direction - expands right for left-positioned buttons */ .triagly-shape-expandable.triagly-expand-right { flex-direction: row; } .triagly-shape-expandable .triagly-btn-icon { display: inline-block; flex-shrink: 0; transition: margin 0.3s cubic-bezier(0.4, 0, 0.2, 1); } .triagly-shape-expandable .triagly-btn-text { display: inline-block; width: 0; opacity: 0; overflow: hidden; font-size: 14px; font-weight: 500; transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1); } /* Hover state */ .triagly-shape-expandable:hover { width: auto; min-width: auto; padding: 12px 20px; border-radius: 30px; background: var(--triagly-button-bg-hover, #4f46e5); box-shadow: var(--triagly-button-shadow-hover, 0 6px 16px rgba(99, 102, 241, 0.4)); } .triagly-shape-expandable:hover .triagly-btn-text { width: auto; opacity: 1; } /* Button orientations */ .triagly-orientation-horizontal { writing-mode: horizontal-tb; } .triagly-orientation-vertical { writing-mode: vertical-rl; text-orientation: mixed; } .triagly-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: var(--triagly-overlay-bg, rgba(0, 0, 0, 0.5)); z-index: 1000000; display: flex; align-items: center; justify-content: center; animation: triagly-fadeIn 0.2s; } @keyframes triagly-fadeIn { from { opacity: 0; } to { opacity: 1; } } .triagly-modal { background: var(--triagly-modal-bg, #ffffff); border-radius: var(--triagly-modal-radius, 12px); width: 90%; max-width: var(--triagly-modal-max-width, 500px); max-height: 90vh; overflow-y: auto; box-shadow: var(--triagly-modal-shadow, 0 20px 60px rgba(0, 0, 0, 0.3)); animation: triagly-slideUp 0.3s; } @keyframes triagly-slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .triagly-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; background: var(--triagly-header-bg, #ffffff); border-bottom: 1px solid var(--triagly-header-border, #e5e7eb); } .triagly-header h3 { margin: 0; font-size: 18px; font-weight: 600; color: var(--triagly-header-text, #111827); } .triagly-close { background: none; border: none; font-size: 28px; color: #6b7280; cursor: pointer; padding: 0; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 6px; transition: all 0.2s; } .triagly-close:hover { background: #f3f4f6; color: #111827; } .triagly-form { padding: 24px; background: var(--triagly-form-bg, #ffffff); } .triagly-field { margin-bottom: 16px; } .triagly-field label { display: block; margin-bottom: 6px; font-size: 14px; font-weight: 500; color: var(--triagly-label-text, #374151); } .triagly-field input, .triagly-field textarea { width: 100%; padding: 10px 12px; background: var(--triagly-input-bg, #ffffff); border: 1px solid var(--triagly-input-border, #d1d5db); border-radius: var(--triagly-input-radius, 6px); color: var(--triagly-input-text, #111827); font-size: 14px; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; } .triagly-field input:focus, .triagly-field textarea:focus { outline: none; border-color: var(--triagly-input-border-focus, #6366f1); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); } .triagly-checkbox label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 400; } .triagly-checkbox label span { user-select: none; } .triagly-checkbox input { width: 16px; height: 16px; margin: 0; cursor: pointer; } /* Focus visible styles for accessibility */ .triagly-button:focus-visible, .triagly-field input:focus-visible, .triagly-field textarea:focus-visible, .triagly-checkbox input:focus-visible, .triagly-btn-primary:focus-visible, .triagly-btn-secondary:focus-visible, .triagly-close:focus-visible { outline: 2px solid #6366f1; outline-offset: 2px; } /* Checkbox label gets visual indicator when checkbox is focused */ .triagly-checkbox input:focus-visible + span { text-decoration: underline; } .triagly-turnstile { display: flex; justify-content: center; margin: 8px 0; } .triagly-actions { display: flex; gap: 12px; margin-top: 24px; } .triagly-btn-primary, .triagly-btn-secondary { flex: 1; padding: 10px 16px; border-radius: var(--triagly-btn-radius, 6px); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; border: none; } .triagly-btn-primary { background: var(--triagly-btn-primary-bg, #6366f1); color: var(--triagly-btn-primary-text, #ffffff); } .triagly-btn-primary:hover:not(:disabled) { background: var(--triagly-btn-primary-bg-hover, #4f46e5); } .triagly-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } .triagly-btn-secondary { background: var(--triagly-btn-secondary-bg, #f3f4f6); color: var(--triagly-btn-secondary-text, #374151); } .triagly-btn-secondary:hover { background: var(--triagly-btn-secondary-bg-hover, #e5e7eb); } .triagly-status { margin-top: 16px; padding: 12px; border-radius: 6px; font-size: 14px; display: none; } .triagly-status.triagly-success { display: block; background: var(--triagly-success-bg, #d1fae5); color: var(--triagly-success-text, #065f46); } .triagly-status.triagly-error { display: block; background: var(--triagly-error-bg, #fee2e2); color: var(--triagly-error-text, #991b1b); } `; document.head.appendChild(style); } /** * Set up keyboard event handlers */ setupKeyboardEvents() { const handleKeyDown = (e) => { // Close on Escape key if (e.key === 'Escape' && this.isOpen) { e.preventDefault(); this.close('dismiss'); } }; document.addEventListener('keydown', handleKeyDown); // Store handler for cleanup if (this.container) { this.container._keydownHandler = handleKeyDown; } } /** * Set up focus trap to keep focus within modal */ setupFocusTrap() { if (!this.container) return; // Get all focusable elements const modal = this.container.querySelector('.triagly-modal'); if (!modal) return; const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; this.focusableElements = Array.from(modal.querySelectorAll(focusableSelector)); if (this.focusableElements.length === 0) { console.warn('Triagly: No focusable elements found in modal'); return; } // Handle Tab key to trap focus - must prevent default BEFORE focus moves const handleTab = (e) => { const keyEvent = e; if (keyEvent.key !== 'Tab') return; // Only handle if focus is within our modal if (!this.container?.contains(document.activeElement)) return; const firstFocusable = this.focusableElements[0]; const lastFocusable = this.focusableElements[this.focusableElements.length - 1]; if (keyEvent.shiftKey) { // Shift + Tab - moving backwards if (document.activeElement === firstFocusable) { keyEvent.preventDefault(); lastFocusable?.focus(); } } else { // Tab - moving forwards if (document.activeElement === lastFocusable) { keyEvent.preventDefault(); firstFocusable?.focus(); } } }; // Use capture phase to intercept Tab before browser handles it document.addEventListener('keydown', handleTab, true); this.container._tabHandler = handleTab; } /** * Destroy the widget */ destroy() { // Clean up event listeners if (this.container) { const keydownHandler = this.container._keydownHandler; if (keydownHandler) { document.removeEventListener('keydown', keydownHandler); } const tabHandler = this.container._tabHandler; if (tabHandler) { document.removeEventListener('keydown', tabHandler, true); } } this.close(); document.getElementById('triagly-button')?.remove(); document.getElementById('triagly-styles')?.remove(); } } // API Client const DEFAULT_API_URL = 'https://sypkjlwfyvyuqnvzkaxb.supabase.co/functions/v1'; class TriaglyAPI { constructor(publishableKey, apiUrl, getToken, turnstileSiteKey) { this.apiUrl = (apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); // Remove trailing slash this.publishableKey = publishableKey; this.getToken = getToken; this.turnstileSiteKey = turnstileSiteKey; } /** * Get Turnstile token from widget if available */ async getTurnstileToken() { // Check if Turnstile widget is available const turnstileWidget = document.querySelector('[data-turnstile-response]'); if (turnstileWidget) { const token = turnstileWidget.getAttribute('data-turnstile-response'); if (token) return token; } // Check if window.turnstile is available if (window.turnstile) { try { // Get the first widget's response const widgets = document.querySelectorAll('.cf-turnstile'); if (widgets.length > 0) { const widgetId = widgets[0].getAttribute('data-widget-id'); if (widgetId) { const token = window.turnstile.getResponse(widgetId); if (token) return token; } } } catch (error) { console.warn('Failed to get Turnstile token:', error); } } return null; } /** * Submit feedback with new authentication */ async submitFeedback(data, metadata, turnstileToken) { // Get Turnstile token if not provided if (!turnstileToken) { turnstileToken = await this.getTurnstileToken() || undefined; } // Only require Turnstile if configured if (this.turnstileSiteKey && !turnstileToken) { throw new Error('Turnstile verification required. Please complete the captcha.'); } // Get hardened token if callback is provided let hardenedToken; if (this.getToken) { try { hardenedToken = await this.getToken(); } catch (error) { console.error('Failed to get hardened token:', error); throw new Error('Failed to authenticate. Please try again.'); } } const payload = { publishableKey: this.publishableKey, title: data.title, description: data.description, metadata: { ...metadata, consoleLogs: data.consoleLogs, }, tags: data.tags, screenshot: data.screenshot, reporterEmail: data.reporterEmail, reporterName: data.reporterName, turnstileToken, hardenedToken, }; const response = await fetch(`${this.apiUrl}/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Unknown error', message: 'Unknown error' })); // Handle specific error types with user-friendly messages if (response.status === 401) { if (error.error === 'invalid_publishable_key') { throw new Error('Invalid API key. Please contact support.'); } else if (error.error === 'token_required') { throw new Error('Authentication required. Please refresh and try again.'); } throw new Error(error.message || 'Authentication failed'); } else if (response.status === 403) { if (error.error === 'origin_not_allowed') { throw new Error('This website is not authorized to submit feedback.'); } throw new Error(error.message || 'Access denied'); } else if (response.status === 429) { const retryAfter = response.headers.get('Retry-After'); const resetTime = retryAfter ? `in ${retryAfter} seconds` : 'later'; throw new Error(`Too many requests. Please try again ${resetTime}.`); } else if (response.status === 400 && error.error === 'captcha_failed') { throw new Error('Captcha verification failed. Please try again.'); } throw new Error(error.message || error.error || `Failed to submit feedback (HTTP ${response.status})`); } return await response.json(); } } // Utility functions /** * Collect browser and page metadata */ function collectMetadata(customMetadata) { const viewport = `${window.innerWidth}x${window.innerHeight}`; const browser = detectBrowser(); return { url: window.location.href, browser, viewport, userAgent: navigator.userAgent, timestamp: new Date().toISOString(), ...customMetadata, }; } /** * Detect browser name and version */ function detectBrowser() { const ua = navigator.userAgent; let browser = 'Unknown'; if (ua.includes('Firefox/')) { const version = ua.match(/Firefox\/(\d+)/)?.[1]; browser = `Firefox ${version}`; } else if (ua.includes('Chrome/') && !ua.includes('Edg')) { const version = ua.match(/Chrome\/(\d+)/)?.[1]; browser = `Chrome ${version}`; } else if (ua.includes('Safari/') && !ua.includes('Chrome')) { const version = ua.match(/Version\/(\d+)/)?.[1]; browser = `Safari ${version}`; } else if (ua.includes('Edg/')) { const version = ua.match(/Edg\/(\d+)/)?.[1]; browser = `Edge ${version}`; } return browser; } /** * Capture screenshot of current page */ async function captureScreenshot() { try { // Use html2canvas library if available if (typeof window.html2canvas !== 'undefined') { const canvas = await window.html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, }); return canvas.toDataURL('image/png'); } // Fallback to native screenshot API if supported (limited browser support) if ('mediaDevices' in navigator && 'getDisplayMedia' in navigator.mediaDevices) { // This requires user interaction and shows a permission dialog // Not ideal for automatic screenshots console.warn('Screenshot capture requires html2canvas library'); return null; } return null; } catch (error) { console.error('Screenshot capture failed:', error); return null; } } /** * Simple rate limiter using localStorage */ class RateLimiter { constructor(key, maxAttempts = 3, windowMs = 5 * 60 * 1000) { this.key = `triagly_ratelimit_${key}`; this.maxAttempts = maxAttempts; this.windowMs = windowMs; } canProceed() { const now = Date.now(); const data = this.getData(); // Filter out old attempts const recentAttempts = data.attempts.filter((timestamp) => now - timestamp < this.windowMs); if (recentAttempts.length >= this.maxAttempts) { return false; } return true; } recordAttempt() { const now = Date.now(); const data = this.getData(); // Add new attempt data.attempts.push(now); // Keep only recent attempts data.attempts = data.attempts.filter((timestamp) => now - timestamp < this.windowMs); this.setData(data); } getTimeUntilReset() { const now = Date.now(); const data = this.getData(); if (data.attempts.length === 0) { return 0; } const oldestAttempt = Math.min(...data.attempts); const resetTime = oldestAttempt + this.windowMs; return Math.max(0, resetTime - now); } getData() { try { const stored = localStorage.getItem(this.key); return stored ? JSON.parse(stored) : { attempts: [] }; } catch { return { attempts: [] }; } } setData(data) { try { localStorage.setItem(this.key, JSON.stringify(data)); } catch (error) { console.error('Failed to store rate limit data:', error); } } } class ConsoleLogger { constructor(maxLogs = 50, levels = ['error', 'warn']) { this.buffer = []; this.isActive = false; this.maxLogs = maxLogs; this.levels = new Set(levels); // Store original console methods this.originalConsole = { error: console.error, warn: console.warn, log: console.log, }; } /** * Start capturing console logs */ start() { if (this.isActive) return; this.isActive = true; // Intercept console.error if (this.levels.has('error')) { console.error = (...args) => { this.captureLog('error', args); this.originalConsole.error.apply(console, args); }; } // Intercept console.warn if (this.levels.has('warn')) { console.warn = (...args) => { this.captureLog('warn', args); this.originalConsole.warn.apply(console, args); }; } // Intercept console.log if (this.levels.has('log')) { console.log = (...args) => { this.captureLog('log', args); this.originalConsole.log.apply(console, args); }; } } /** * Stop capturing and restore original console methods */ stop() { if (!this.isActive) return; this.isActive = false; console.error = this.originalConsole.error; console.warn = this.originalConsole.warn; console.log = this.originalConsole.log; } /** * Capture a log entry */ captureLog(level, args) { try { // Convert arguments to string const message = args.map(arg => { if (typeof arg === 'string') return arg; if (arg instanceof Error) return arg.message; try { return JSON.stringify(arg); } catch { return String(arg); } }).join(' '); // Get stack trace for errors let stack; if (level === 'error') { const error = args.find(arg => arg instanceof Error); if (error) { stack = error.stack; } else { // Create stack trace stack = new Error().stack?.split('\n').slice(2).join('\n'); } } // Sanitize sensitive data const sanitized = this.sanitize(message); const sanitizedStack = stack ? this.sanitize(stack) : undefined; // Add to buffer const logEntry = { level, message: sanitized, timestamp: new Date().toISOString(), stack: sanitizedStack, }; this.buffer.push(logEntry); // Keep buffer size limited (circular buffer) if (this.buffer.length > this.maxLogs) { this.buffer.shift(); } } catch (error) { // Don't let logging break the app this.originalConsole.error('Failed to capture log:', error); } } /** * Sanitize sensitive data from logs */ sanitize(text) { return text // API keys, tokens, secrets .replace(/[a-zA-Z0-9_-]*token[a-zA-Z0-9_-]*\s*[:=]\s*["']?[\w-]{20,}["']?/gi, 'token=***') .replace(/[a-zA-Z0-9_-]*key[a-zA-Z0-9_-]*\s*[:=]\s*["']?[\w-]{20,}["']?/gi, 'key=***') .replace(/[a-zA-Z0-9_-]*secret[a-zA-Z0-9_-]*\s*[:=]\s*["']?[\w-]{20,}["']?/gi, 'secret=***') // GitHub tokens (ghp_, gho_, etc.) .replace(/gh[ps]_[a-zA-Z0-9]{36,}/g, 'gh*_***') // JWT tokens .replace(/eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, 'jwt.***') // Passwords .replace(/password\s*[:=]\s*["']?[^"'\s]+["']?/gi, 'password=***') // Email addresses .replace(/\b[\w._%+-]+@[\w.-]+\.[a-zA-Z]{2,}\b/g, '***@***.com') // Credit cards (basic pattern) .replace(/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g, '****-****-****-****') // URLs with tokens in query params .replace(/([?&])(token|key|secret|auth)=[^&\s]+/gi, '$1$2=***'); } /** * Get all captured logs */ getLogs() { return [...this.buffer]; } /** * Clear all captured logs */ clear() { this.buffer = []; } /** * Get logs count */ getCount() { return this.buffer.length; } } // Triagly SDK Main Entry Point class Triagly { constructor(config) { this.consoleLogger = null; // Handle backward compatibility let publishableKey = config.publishableKey; if (!publishableKey && config.projectId) { console.warn('Triagly: projectId is deprecated. Please use publishableKey instead. ' + 'See migration guide: https://docs.triagly.com/sdk/migration'); publishableKey = config.projectId; } if (!publishableKey) { throw new Error('Triagly: publishableKey is required. Get yours at https://triagly.com/dashboard'); } this.config = { theme: 'auto', position: 'bottom-right', buttonShape: 'rounded', buttonText: '🐛 Feedback', placeholderText: 'Describe what happened...', successMessage: 'Feedback sent successfully!', errorMessage: 'Failed to send feedback. Please try again.', captureConsole: true, consoleLogLimit: 50, consoleLogLevels: ['error', 'warn'], ...config, publishableKey, }; this.api = new TriaglyAPI(this.config.publishableKey, this.config.apiUrl, this.config.getToken, this.config.turnstileSiteKey); this.widget = new FeedbackWidget(this.config); this.rateLimiter = new RateLimiter(this.config.publishableKey, 3, 5 * 60 * 1000); // Initialize console logger if enabled if (this.config.captureConsole !== false) { this.consoleLogger = new ConsoleLogger(this.config.consoleLogLimit, this.config.consoleLogLevels); this.consoleLogger.start(); } this.init(); } /** * Initialize the SDK */ init() { // Initialize widget this.widget.init(); // Listen for form submissions document.addEventListener('triagly:submit', (e) => { const customEvent = e; this.handleSubmit(customEvent.detail); }); } /** * Handle feedback submission */ async handleSubmit(data) { try { // Check rate limit if (!this.rateLimiter.canProceed()) { const resetTime = Math.ceil(this.rateLimiter.getTimeUntilReset() / 1000 / 60); throw new Error(`Rate limit exceeded. Please try again in ${resetTime} minute(s).`); } // Collect metadata const metadata = collectMetadata(this.config.metadata); // Capture screenshot if requested let screenshot = null; if (data.includeScreenshot) { screenshot = await captureScreenshot(); } // Prepare feedback data const feedbackData = { title: data.title, description: data.description, reporterEmail: data.reporterEmail, screenshot: screenshot || undefined, consoleLogs: this.consoleLogger?.getLogs(), }; // Submit to API with Turnstile token if provided const response = await this.api.submitFeedback(feedbackData, metadata, data.turnstileToken); // Record rate limit attempt this.rateLimiter.recordAttempt(); // Dispatch success event for UI layer document.dispatchEvent(new CustomEvent('triagly:success', { detail: { feedbackId: response.id } })); // Call success callback if (this.config.onSuccess) { this.config.onSuccess(response.id); } console.log('Feedback submitted successfully:', response.id); } catch (error) { console.error('Failed to submit feedback:', error); // Dispatch error event for UI layer document.dispatchEvent(new CustomEvent('triagly:error', { detail: error })); // Call error callback if (this.config.onError && error instanceof Error) { this.config.onError(error); } throw error; } } /** * Programmatically open the feedback widget */ open() { this.widget.open(); } /** * Programmatically close the feedback widget */ close() { this.widget.close(); } /** * Submit feedback programmatically without UI * Note: When Turnstile is enabled, you must use the widget UI or provide a token */ async submit(data, turnstileToken) { // Require Turnstile token if configured if (this.config.turnstileSiteKey && !turnstileToken) { throw new Error('Turnstile verification required. When Turnstile is enabled, you must:\n' + '1. Use the widget UI (triagly.open()), or\n' + '2. Implement Turnstile in your form and pass the token: triagly.submit(data, token)'); } const metadata = collectMetadata(this.config.metadata); // Include console logs if available and not already provided if (!data.consoleLogs && this.consoleLogger) { data.consoleLogs = this.consoleLogger.getLogs(); } await this.api.submitFeedback(data, metadata, turnstileToken); this.rateLimiter.recordAttempt(); } /** * Destroy the SDK instance */ destroy() { this.widget.destroy(); this.consoleLogger?.stop(); document.removeEventListener('triagly:submit', () => { }); } } // Auto-initialize if config is in window if (typeof window !== 'undefined') { const globalConfig = window.TriaglyConfig; if (globalConfig) { window.triagly = new Triagly(globalConfig); } } export { Triagly, Triagly as default }; //# sourceMappingURL=index.esm.js.map