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
This commit is contained in:
parent
c720f1c8de
commit
307b84c2a2
21
packages/survey-client/package.json
Normal file
21
packages/survey-client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
328
packages/survey-client/src/index.ts
Normal file
328
packages/survey-client/src/index.ts
Normal file
@ -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<string, QuestionAnswer>;
|
||||||
|
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<string>);
|
||||||
|
/** 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<string, QuestionAnswer>;
|
||||||
|
}>;
|
||||||
|
/** Submit an answer */
|
||||||
|
submitAnswer(
|
||||||
|
surveyId: string,
|
||||||
|
questionId: string,
|
||||||
|
answer: QuestionAnswer
|
||||||
|
): Promise<{
|
||||||
|
responseId: string;
|
||||||
|
currentQuestionIndex: number;
|
||||||
|
answers: Record<string, QuestionAnswer>;
|
||||||
|
}>;
|
||||||
|
/** Complete the survey */
|
||||||
|
completeSurvey(surveyId: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
timeSpentSeconds: number;
|
||||||
|
incentiveClaimed: boolean;
|
||||||
|
}>;
|
||||||
|
/** Dismiss survey (won't show again) */
|
||||||
|
dismissSurvey(surveyId: string): Promise<void>;
|
||||||
|
/** 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<SurveyResponse>): 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 <T>(path: string, options?: RequestInit): Promise<T> => {
|
||||||
|
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<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In-memory cache for offline support
|
||||||
|
const responseCache = new Map<string, SurveyResponse>();
|
||||||
|
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | 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<string, QuestionAnswer>;
|
||||||
|
}>(`/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<string, QuestionAnswer>;
|
||||||
|
}>(`/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<void>(`/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<SurveyResponse>) {
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
10
packages/survey-client/tsconfig.json
Normal file
10
packages/survey-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"declaration": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user