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