@triagly/sdk
Version:
JavaScript SDK for Triagly - Turn user feedback into GitHub issues instantly
1,341 lines (1,291 loc) • 46.7 kB
JavaScript
// 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