feat(feedback-client): Phase 2.1 - create @bytelyst/feedback-client TypeScript SDK

This commit is contained in:
saravanakumardb1 2026-03-03 00:03:03 -08:00
parent 3ded5ad751
commit b261cda1cd
4 changed files with 317 additions and 0 deletions

View File

@ -0,0 +1,31 @@
{
"name": "@bytelyst/feedback-client",
"version": "0.1.0",
"description": "TypeScript client for submitting user feedback with screenshots",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run"
},
"dependencies": {
"@bytelyst/api-client": "workspace:*"
},
"peerDependencies": {
"zod": "^3.22.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest';
import { FeedbackClient, type SubmitFeedbackParams } from './index.js';
import type { ApiClient } from '@bytelyst/api-client';
describe('FeedbackClient', () => {
const mockApi: Partial<ApiClient> = {
post: vi.fn(),
};
const createClient = () => new FeedbackClient(mockApi as ApiClient);
it('should submit feedback without screenshot', async () => {
const client = createClient();
const mockResponse = {
id: 'fb_123',
productId: 'test',
userId: 'user_123',
type: 'bug',
title: 'Test bug',
status: 'new',
createdAt: new Date().toISOString(),
};
mockApi.post = vi.fn().mockResolvedValue(mockResponse);
const result = await client.submitWithScreenshot({
type: 'bug',
title: 'Test bug',
body: 'Description',
});
expect(result).toEqual(mockResponse);
expect(mockApi.post).toHaveBeenCalledWith('/api/feedback', expect.objectContaining({
type: 'bug',
title: 'Test bug',
body: 'Description',
}));
});
it('should throw if captureAndSubmit called without screenshot', async () => {
const client = createClient();
await expect(
client.captureAndSubmit({ type: 'bug', title: 'Test' })
).rejects.toThrow('TODO-1');
});
});

View File

@ -0,0 +1,229 @@
/**
* 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 | Promise<string>;
}
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;
/**
* 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.post<FeedbackResponse>('/api/feedback', {
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.post<SasResponse>('/api/feedback/sas', {
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 and submit feedback in one operation
*
* TODO-1: Implement platform-specific screenshot capture
* - Web: Use html2canvas or getDisplayMedia
* - React Native: Use react-native-view-shot
* - Electron: Use desktopCapturer
*
* For now, requires screenshot blob to be provided by caller
*/
async captureAndSubmit(
params: Omit<SubmitFeedbackParams, 'screenshot'> & { screenshot?: Blob },
onProgress?: UploadProgressCallback
): Promise<FeedbackResponse> {
if (!params.screenshot) {
throw new Error('TODO-1: Auto-capture not yet implemented. Provide screenshot blob.');
}
return this.submitWithScreenshot({
...params,
screenshot: {
blob: params.screenshot,
contentType: 'image/png',
},
}, onProgress);
}
}
// Re-export types
export type { ApiClient } from '@bytelyst/api-client';

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}