feat(feedback-client): Phase 2.1 - create @bytelyst/feedback-client TypeScript SDK
This commit is contained in:
parent
3ded5ad751
commit
b261cda1cd
31
packages/feedback-client/package.json
Normal file
31
packages/feedback-client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
47
packages/feedback-client/src/index.test.ts
Normal file
47
packages/feedback-client/src/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
229
packages/feedback-client/src/index.ts
Normal file
229
packages/feedback-client/src/index.ts
Normal 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';
|
||||
10
packages/feedback-client/tsconfig.json
Normal file
10
packages/feedback-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user