/** * Feedback Client — TypeScript SDK for user feedback with screenshots * * @module @bytelyst/feedback-client */ import { createApiClient, type ApiClient } from '@bytelyst/api-client'; export interface FeedbackClientConfig { baseUrl: string; getAuthToken: () => string | null; } export interface DeviceContext { osVersion: string; appVersion: string; deviceModel: string; screenResolution: string; locale: string; } export interface SubmitFeedbackParams { type: 'bug' | 'feature' | 'praise' | 'other'; title: string; body?: string; screen?: string; rating?: number; appVersion?: string; platform?: 'web' | 'ios' | 'android'; screenshot?: { blob: Blob; contentType: 'image/png' | 'image/jpeg' | 'image/webp'; }; deviceContext?: DeviceContext; } export interface SasResponse { blobPath: string; uploadUrl: string; expiresIn: number; maxSizeBytes: number; } export interface FeedbackResponse { id: string; productId: string; userId: string; type: string; title: string; status: string; createdAt: string; screenshotBlobPath?: string; } export type UploadProgressCallback = (loaded: number, total: number) => void; export interface ScreenshotOptions { /** For web: CSS selector of element to capture. If omitted, captures viewport */ selector?: string; /** Image format */ format?: 'png' | 'jpeg' | 'webp'; /** JPEG quality (0-1), only used for jpeg format */ quality?: number; } export interface CaptureResult { blob: Blob; contentType: 'image/png' | 'image/jpeg' | 'image/webp'; width: number; height: number; } /** * Create a feedback client for submitting user feedback with optional screenshots */ export function createFeedbackClient(config: FeedbackClientConfig) { const api = createApiClient({ baseUrl: config.baseUrl, getToken: config.getAuthToken, }); return new FeedbackClient(api); } /** * Feedback client class for submitting feedback with screenshots */ export class FeedbackClient { constructor(private api: ApiClient) {} /** * Submit feedback with optional screenshot * * Flow: * 1. If screenshot provided, get SAS URL * 2. Upload screenshot to blob storage * 3. Submit feedback with screenshot metadata */ async submitWithScreenshot( params: SubmitFeedbackParams, onProgress?: UploadProgressCallback ): Promise { let screenshotMeta: { blobPath: string; contentType: string; sizeBytes: number } | undefined; // Step 1 & 2: Handle screenshot upload if provided if (params.screenshot) { const sas = await this.generateSasUrl(params.screenshot.contentType); await this.uploadScreenshot( sas.uploadUrl, params.screenshot.blob, onProgress ); screenshotMeta = { blobPath: sas.blobPath, contentType: params.screenshot.contentType, sizeBytes: params.screenshot.blob.size, }; } // Step 3: Submit feedback const response = await this.api.fetch('/api/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: params.type, title: params.title, body: params.body, screen: params.screen, rating: params.rating, appVersion: params.appVersion, platform: params.platform, screenshotBlobPath: screenshotMeta?.blobPath, screenshotContentType: screenshotMeta?.contentType as 'image/png' | 'image/jpeg' | 'image/webp', screenshotSizeBytes: screenshotMeta?.sizeBytes, deviceContext: params.deviceContext, }), }); return response; } /** * Generate SAS URL for screenshot upload */ private async generateSasUrl( contentType: 'image/png' | 'image/jpeg' | 'image/webp' ): Promise { const response = await this.api.fetch('/api/feedback/sas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contentType }), }); return response; } /** * Upload screenshot directly to Azure Blob */ private async uploadScreenshot( uploadUrl: string, blob: Blob, onProgress?: UploadProgressCallback ): Promise { // Use XMLHttpRequest for progress tracking if callback provided if (onProgress && typeof window !== 'undefined') { return this.uploadWithProgress(uploadUrl, blob, onProgress); } // Simple fetch upload const response = await fetch(uploadUrl, { method: 'PUT', headers: { 'Content-Type': blob.type, 'x-ms-blob-type': 'BlockBlob', }, body: blob, }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } } /** * Upload with progress tracking using XMLHttpRequest */ private uploadWithProgress( uploadUrl: string, blob: Blob, onProgress: UploadProgressCallback ): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener('progress', (event: ProgressEvent) => { if (event.lengthComputable) { onProgress(event.loaded, event.total); } }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { resolve(); } else { reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); } }); xhr.addEventListener('error', () => { reject(new Error('Upload failed: Network error')); }); xhr.open('PUT', uploadUrl); xhr.setRequestHeader('Content-Type', blob.type); xhr.setRequestHeader('x-ms-blob-type', 'BlockBlob'); xhr.send(blob); }); } /** * Capture screenshot of current page or element (Web only) * * Uses native getDisplayMedia for screen capture or html2canvas-style * DOM serialization for element capture. */ async captureScreenshot(options: ScreenshotOptions = {}): Promise { // Check if running in browser if (typeof window === 'undefined' || typeof document === 'undefined') { throw new Error('Screenshot capture only available in browser environment'); } const format = options.format || 'png'; const mimeType = format === 'png' ? 'image/png' : format === 'jpeg' ? 'image/jpeg' : 'image/webp'; // If selector provided, capture specific element if (options.selector) { return this.captureElement(options.selector, mimeType, options.quality); } // Otherwise capture full screen using getDisplayMedia return this.captureScreen(mimeType); } /** * Capture entire screen using getDisplayMedia */ private async captureScreen(mimeType: string): Promise { try { // Request screen capture permission const stream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: false, }); // Create video element to capture frame const video = document.createElement('video'); video.srcObject = stream; // Wait for video to load await new Promise((resolve, reject) => { video.onloadedmetadata = () => { video.play(); resolve(); }; video.onerror = () => reject(new Error('Failed to load video stream')); // Timeout after 5 seconds setTimeout(() => reject(new Error('Video load timeout')), 5000); }); // Draw to canvas const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Failed to get canvas context'); ctx.drawImage(video, 0, 0); // Stop all tracks stream.getTracks().forEach(track => track.stop()); // Convert to blob const quality = mimeType === 'image/jpeg' ? 0.9 : undefined; const blob = await new Promise((resolve, reject) => { canvas.toBlob( (b) => b ? resolve(b) : reject(new Error('Canvas toBlob failed')), mimeType, quality ); }); return { blob, contentType: mimeType as 'image/png' | 'image/jpeg' | 'image/webp', width: canvas.width, height: canvas.height, }; } catch (err) { throw new Error(`Screen capture failed: ${err instanceof Error ? err.message : String(err)}`); } } /** * Capture specific DOM element using html-to-image approach */ private async captureElement( selector: string, mimeType: string, quality?: number ): Promise { const element = document.querySelector(selector); if (!element) { throw new Error(`Element not found: ${selector}`); } // Use html-to-image or similar approach // For now, we'll use a simple canvas-based approach for visible elements const rect = element.getBoundingClientRect(); // Create canvas const canvas = document.createElement('canvas'); canvas.width = rect.width; canvas.height = rect.height; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Failed to get canvas context'); // Try to use html2canvas-style approach if available, otherwise warn // This is a simplified implementation throw new Error( 'Element capture requires html2canvas library. ' + 'Please install: npm install html2canvas ' + 'Then use: html2canvas(element).then(canvas => canvas.toBlob(...))' ); } /** * Capture and submit feedback in one operation * * @example * // Capture full screen and submit * const result = await client.captureAndSubmit({ * type: 'bug', * title: 'Something is broken', * body: 'Description of the issue' * }); * * @example * // Capture specific element * const result = await client.captureAndSubmit({ * type: 'bug', * title: 'Button not working', * body: 'The submit button is unresponsive' * }, { * selector: '#submit-button' * }); */ async captureAndSubmit( params: Omit, screenshotOptions?: ScreenshotOptions, onProgress?: UploadProgressCallback ): Promise { // Capture screenshot const capture = await this.captureScreenshot(screenshotOptions); // Submit with captured screenshot return this.submitWithScreenshot({ ...params, screenshot: { blob: capture.blob, contentType: capture.contentType, }, }, onProgress); } }