From 307b84c2a24913d33df4b862c1867485305875ce Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 07:36:12 -0800 Subject: [PATCH] feat(packages): Phase 3.2 - Create @bytelyst/survey-client package - package.json: ESM module config - src/index.ts: Survey client factory with types, validation, offline cache - tsconfig.json: TypeScript configuration Includes offline response caching for resilience --- packages/survey-client/package.json | 21 ++ packages/survey-client/src/index.ts | 328 +++++++++++++++++++++++++++ packages/survey-client/tsconfig.json | 10 + 3 files changed, 359 insertions(+) create mode 100644 packages/survey-client/package.json create mode 100644 packages/survey-client/src/index.ts create mode 100644 packages/survey-client/tsconfig.json diff --git a/packages/survey-client/package.json b/packages/survey-client/package.json new file mode 100644 index 00000000..6927ca0d --- /dev/null +++ b/packages/survey-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bytelyst/survey-client", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe survey client for platform-service", + "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" + } +} diff --git a/packages/survey-client/src/index.ts b/packages/survey-client/src/index.ts new file mode 100644 index 00000000..bb2681e3 --- /dev/null +++ b/packages/survey-client/src/index.ts @@ -0,0 +1,328 @@ +/** + * Survey Client — Browser/React Native-safe survey client + * @module @bytelyst/survey-client + */ + +// ============================================================================= +// Types +// ============================================================================= + +export type QuestionType = + | 'single_choice' + | 'multiple_choice' + | 'rating' + | 'nps' + | 'text_short' + | 'text_long' + | 'dropdown' + | 'scale' + | 'ranking'; + +export interface QuestionOption { + id: string; + text: string; + emoji?: string; +} + +export interface Question { + id: string; + type: QuestionType; + text: string; + description?: string; + required: boolean; + options?: QuestionOption[]; + minLength?: number; + maxLength?: number; + minValue?: number; + maxValue?: number; +} + +export interface Survey { + id: string; + productId: string; + title: string; + description?: string; + questions: Question[]; + status: 'draft' | 'active' | 'paused' | 'closed'; + startsAt?: string; + endsAt?: string; + displayTrigger: + | { type: 'immediate' } + | { type: 'delay_seconds'; seconds: number } + | { type: 'event'; eventName: string } + | { type: 'page_view'; pagePattern: string }; + incentive?: { type: 'pro_days' | 'credits'; amount: number }; + createdAt: string; + updatedAt: string; + createdBy: string; +} + +export type QuestionAnswer = + | { type: 'single_choice'; optionId: string } + | { type: 'multiple_choice'; optionIds: string[] } + | { type: 'rating'; value: number } + | { type: 'nps'; value: number } + | { type: 'text'; value: string } + | { type: 'ranking'; rankedOptionIds: string[] }; + +export interface SurveyResponse { + id: string; + surveyId: string; + userId: string; + answers: Record; + currentQuestionIndex: number; + startedAt: string; + completedAt?: string; + isComplete: boolean; + incentiveClaimed: boolean; + incentiveClaimedAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface SurveyClientConfig { + /** Platform service base URL */ + baseUrl: string; + /** Product ID */ + productId: string; + /** Auth token provider */ + getAuthToken: (() => string) | (() => Promise); + /** Platform identifier */ + platform: 'web' | 'ios' | 'android' | 'macos' | 'windows'; + /** App version */ + appVersion: string; + /** OS version */ + osVersion: string; + /** Optional country code */ + countryCode?: string; + /** Optional region code */ + regionCode?: string; + /** User segments (default: ['free']) */ + userSegments?: string[]; + /** Device ID for tracking */ + deviceId?: string; +} + +// ============================================================================= +// Client Factory +// ============================================================================= + +export interface ActiveSurvey { + id: string; + title: string; + description?: string; + questions: Question[]; + incentive?: { type: 'pro_days' | 'credits'; amount: number }; + displayTrigger: Survey['displayTrigger']; +} + +export interface SurveyClient { + /** Get active survey for current user (if any) */ + getActiveSurvey(): Promise<{ survey: ActiveSurvey | null }>; + /** Start a survey session */ + startSurvey(surveyId: string): Promise<{ + responseId: string; + startedAt: string; + currentQuestionIndex: number; + answers: Record; + }>; + /** Submit an answer */ + submitAnswer( + surveyId: string, + questionId: string, + answer: QuestionAnswer + ): Promise<{ + responseId: string; + currentQuestionIndex: number; + answers: Record; + }>; + /** Complete the survey */ + completeSurvey(surveyId: string): Promise<{ + success: boolean; + timeSpentSeconds: number; + incentiveClaimed: boolean; + }>; + /** Dismiss survey (won't show again) */ + dismissSurvey(surveyId: string): Promise; + /** Check for eligible surveys periodically */ + pollSurveys(intervalMs?: number): () => void; + /** Get cached response for a survey (for offline support) */ + getCachedResponse(surveyId: string): SurveyResponse | null; + /** Save response to cache */ + cacheResponse(surveyId: string, response: Partial): void; +} + +export function createSurveyClient(config: SurveyClientConfig): SurveyClient { + const headers = async () => ({ + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${await Promise.resolve(config.getAuthToken())}`, + 'x-product-id': config.productId, + 'x-platform': config.platform, + 'x-app-version': config.appVersion, + 'x-os-version': config.osVersion, + ...(config.countryCode && { 'x-country-code': config.countryCode }), + ...(config.regionCode && { 'x-region-code': config.regionCode }), + 'x-user-segments': (config.userSegments ?? ['free']).join(','), + ...(config.deviceId && { 'x-device-id': config.deviceId }), + }); + + const request = async (path: string, options?: RequestInit): Promise => { + const res = await fetch(`${config.baseUrl}${path}`, { + ...options, + headers: { + ...(await headers()), + ...(options?.headers || {}), + }, + }); + if (!res.ok) { + const err = await res.text(); + throw new Error(`Survey API error: ${res.status} ${err}`); + } + return res.json() as Promise; + }; + + // In-memory cache for offline support + const responseCache = new Map(); + + let pollInterval: ReturnType | null = null; + + return { + async getActiveSurvey() { + return request<{ survey: ActiveSurvey | null }>('/surveys/active'); + }, + + async startSurvey(surveyId: string) { + const result = await request<{ + responseId: string; + startedAt: string; + currentQuestionIndex: number; + answers: Record; + }>(`/surveys/${surveyId}/start`, { method: 'POST' }); + // Cache the initial response + this.cacheResponse(surveyId, { + id: result.responseId, + surveyId, + userId: '', // Will be filled by server + answers: result.answers, + currentQuestionIndex: result.currentQuestionIndex, + startedAt: result.startedAt, + isComplete: false, + incentiveClaimed: false, + createdAt: result.startedAt, + updatedAt: result.startedAt, + }); + return result; + }, + + async submitAnswer(surveyId: string, questionId: string, answer: QuestionAnswer) { + const result = await request<{ + responseId: string; + currentQuestionIndex: number; + answers: Record; + }>(`/surveys/${surveyId}/response`, { + method: 'POST', + body: JSON.stringify({ questionId, answer }), + }); + // Update cache + const cached = responseCache.get(surveyId); + if (cached) { + cached.answers = result.answers; + cached.currentQuestionIndex = result.currentQuestionIndex; + cached.updatedAt = new Date().toISOString(); + } + return result; + }, + + async completeSurvey(surveyId: string) { + const result = await request<{ + success: boolean; + timeSpentSeconds: number; + incentiveClaimed: boolean; + }>(`/surveys/${surveyId}/complete`, { method: 'POST' }); + // Clear cache on completion + responseCache.delete(surveyId); + return result; + }, + + async dismissSurvey(surveyId: string) { + await request(`/surveys/${surveyId}/dismiss`, { method: 'POST' }); + responseCache.delete(surveyId); + }, + + pollSurveys(intervalMs = 60000) { + if (pollInterval) clearInterval(pollInterval); + pollInterval = setInterval(() => { + this.getActiveSurvey().catch(() => {}); + }, intervalMs); + return () => { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + }; + }, + + getCachedResponse(surveyId: string) { + return responseCache.get(surveyId) ?? null; + }, + + cacheResponse(surveyId: string, response: Partial) { + const existing = responseCache.get(surveyId); + responseCache.set(surveyId, { + ...existing, + ...response, + } as SurveyResponse); + }, + }; +} + +// ============================================================================= +// Answer Validation +// ============================================================================= + +export function validateAnswer( + question: Question, + answer: QuestionAnswer +): { valid: boolean; error?: string } { + // Type matching + const expectedType = question.type === 'dropdown' ? 'single_choice' : + question.type === 'scale' ? 'rating' : + question.type === 'text_short' || question.type === 'text_long' ? 'text' : + question.type; + + if (answer.type !== expectedType) { + return { valid: false, error: `Expected ${expectedType}, got ${answer.type}` }; + } + + // Value range validation + if (answer.type === 'rating' || answer.type === 'nps') { + if (question.minValue !== undefined && answer.value < question.minValue) { + return { valid: false, error: `Value below minimum ${question.minValue}` }; + } + if (question.maxValue !== undefined && answer.value > question.maxValue) { + return { valid: false, error: `Value above maximum ${question.maxValue}` }; + } + } + + // Text length validation + if (answer.type === 'text') { + if (question.minLength !== undefined && answer.value.length < question.minLength) { + return { valid: false, error: `Text too short (min ${question.minLength})` }; + } + if (question.maxLength !== undefined && answer.value.length > question.maxLength) { + return { valid: false, error: `Text too long (max ${question.maxLength})` }; + } + } + + return { valid: true }; +} + +// ============================================================================= +// React Hook (optional) +// ============================================================================= + +export function createUseSurvey(client: SurveyClient) { + return function useSurvey() { + return { client }; + }; +} diff --git a/packages/survey-client/tsconfig.json b/packages/survey-client/tsconfig.json new file mode 100644 index 00000000..3686f563 --- /dev/null +++ b/packages/survey-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}