377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
/**
|
|
* 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<FeedbackResponse> {
|
|
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<FeedbackResponse>('/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<SasResponse> {
|
|
const response = await this.api.fetch<SasResponse>('/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<void> {
|
|
// 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<void> {
|
|
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<CaptureResult> {
|
|
// 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<CaptureResult> {
|
|
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<void>((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<Blob>((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<CaptureResult> {
|
|
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<SubmitFeedbackParams, 'screenshot'>,
|
|
screenshotOptions?: ScreenshotOptions,
|
|
onProgress?: UploadProgressCallback
|
|
): Promise<FeedbackResponse> {
|
|
// Capture screenshot
|
|
const capture = await this.captureScreenshot(screenshotOptions);
|
|
|
|
// Submit with captured screenshot
|
|
return this.submitWithScreenshot({
|
|
...params,
|
|
screenshot: {
|
|
blob: capture.blob,
|
|
contentType: capture.contentType,
|
|
},
|
|
}, onProgress);
|
|
}
|
|
}
|