/** * 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 }; }; }