From b261cda1cd3aec20c1b8e319b6a6eb673d2e3db5 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 00:03:03 -0800 Subject: [PATCH] feat(feedback-client): Phase 2.1 - create @bytelyst/feedback-client TypeScript SDK --- packages/feedback-client/package.json | 31 +++ packages/feedback-client/src/index.test.ts | 47 +++++ packages/feedback-client/src/index.ts | 229 +++++++++++++++++++++ packages/feedback-client/tsconfig.json | 10 + 4 files changed, 317 insertions(+) create mode 100644 packages/feedback-client/package.json create mode 100644 packages/feedback-client/src/index.test.ts create mode 100644 packages/feedback-client/src/index.ts create mode 100644 packages/feedback-client/tsconfig.json diff --git a/packages/feedback-client/package.json b/packages/feedback-client/package.json new file mode 100644 index 00000000..39132d5f --- /dev/null +++ b/packages/feedback-client/package.json @@ -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" + } +} diff --git a/packages/feedback-client/src/index.test.ts b/packages/feedback-client/src/index.test.ts new file mode 100644 index 00000000..fe51cf33 --- /dev/null +++ b/packages/feedback-client/src/index.test.ts @@ -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 = { + 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'); + }); +}); diff --git a/packages/feedback-client/src/index.ts b/packages/feedback-client/src/index.ts new file mode 100644 index 00000000..3c0bda7e --- /dev/null +++ b/packages/feedback-client/src/index.ts @@ -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; +} + +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 { + 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('/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 { + const response = await this.api.post('/api/feedback/sas', { + 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 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 & { screenshot?: Blob }, + onProgress?: UploadProgressCallback + ): Promise { + 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'; diff --git a/packages/feedback-client/tsconfig.json b/packages/feedback-client/tsconfig.json new file mode 100644 index 00000000..ce78e59b --- /dev/null +++ b/packages/feedback-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}