diff --git a/backend/services/trialService.ts b/backend/services/trialService.ts new file mode 100644 index 00000000..885bed7e --- /dev/null +++ b/backend/services/trialService.ts @@ -0,0 +1,192 @@ +import { + TrialConfig, + ABTestAssignment, + ConversionFunnelEvent, + TrialReminderSchedule, + TrialStatus, + TrialDuration, + TrialFeatureAccess, + PaymentRequirement, +} from '../../src/types/trial'; + +interface BackendTrialConfig { + id: string; + subscriptionId: string; + duration: TrialDuration; + featureAccess: TrialFeatureAccess; + paymentRequirement: PaymentRequirement; + abTestId?: string; + status: TrialStatus; + startDate?: string; + endDate?: string; + convertedAt?: string; + reminderScheduleId?: string; + metadata?: Record; + createdAt: string; + updatedAt: string; +} + +interface BackendABTestAssignment { + id: string; + abTestId: string; + userId: string; + variantName: string; + assignedAt: string; + cohort?: string; +} + +interface BackendConversionFunnelEvent { + id: string; + trialConfigId: string; + eventType: ConversionFunnelEvent['eventType']; + userId: string; + variantName?: string; + timestamp: string; + metadata?: Record; +} + +export class BackendTrialService { + private trialConfigs: Map = new Map(); + private assignments: Map = new Map(); + private funnelEvents: Map = new Map(); + + createTrialConfig( + subscriptionId: string, + duration: TrialDuration, + featureAccess: TrialFeatureAccess, + paymentRequirement: PaymentRequirement, + abTestId?: string + ): BackendTrialConfig { + const now = new Date().toISOString(); + const endDate = new Date(); + switch (duration) { + case TrialDuration.SEVEN_DAYS: + endDate.setDate(endDate.getDate() + 7); + break; + case TrialDuration.FOURTEEN_DAYS: + endDate.setDate(endDate.getDate() + 14); + break; + case TrialDuration.TWENTY_ONE_DAYS: + endDate.setDate(endDate.getDate() + 21); + break; + case TrialDuration.THIRTY_DAYS: + endDate.setDate(endDate.getDate() + 30); + break; + } + + const config: BackendTrialConfig = { + id: `${subscriptionId}-${Date.now()}`, + subscriptionId, + duration, + featureAccess, + paymentRequirement, + abTestId, + status: TrialStatus.ACTIVE, + startDate: now, + endDate: endDate.toISOString(), + createdAt: now, + updatedAt: now, + }; + + this.trialConfigs.set(config.id, config); + return config; + } + + validateTrialConfig(config: Partial): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!config.subscriptionId) { + errors.push('Subscription ID is required'); + } + if (!config.duration) { + errors.push('Trial duration is required'); + } + if (!config.featureAccess) { + errors.push('Feature access is required'); + } + if (!config.paymentRequirement) { + errors.push('Payment requirement is required'); + } + + return { valid: errors.length === 0, errors }; + } + + assignABTest(abTestId: string, userId: string, variantName: string, cohort?: string): BackendABTestAssignment { + const assignment: BackendABTestAssignment = { + id: `${abTestId}-${userId}-${Date.now()}`, + abTestId, + userId, + variantName, + assignedAt: new Date().toISOString(), + cohort, + }; + + this.assignments.set(assignment.id, assignment); + return assignment; + } + + processFunnelEvent(event: Omit): BackendConversionFunnelEvent { + const funnelEvent: BackendConversionFunnelEvent = { + ...event, + id: `evt-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`, + timestamp: new Date().toISOString(), + }; + + this.funnelEvents.set(funnelEvent.id, funnelEvent); + return funnelEvent; + } + + getTrialConfig(id: string): BackendTrialConfig | undefined { + return this.trialConfigs.get(id); + } + + getTrialConfigBySubscription(subscriptionId: string): BackendTrialConfig | undefined { + for (const config of this.trialConfigs.values()) { + if (config.subscriptionId === subscriptionId) { + return config; + } + } + return undefined; + } + + getAssignmentsForTest(abTestId: string): BackendABTestAssignment[] { + return Array.from(this.assignments.values()).filter((a) => a.abTestId === abTestId); + } + + getFunnelEvents(trialConfigId: string): BackendConversionFunnelEvent[] { + return Array.from(this.funnelEvents.values()).filter((e) => e.trialConfigId === trialConfigId); + } + + getConversionStats(abTestId?: string): { totalTrials: number; convertedTrials: number; conversionRate: number } { + const configs = abTestId + ? Array.from(this.trialConfigs.values()).filter((c) => c.abTestId === abTestId) + : Array.from(this.trialConfigs.values()); + + const totalTrials = configs.length; + const convertedTrials = configs.filter((c) => c.status === TrialStatus.CONVERTED).length; + const conversionRate = totalTrials > 0 ? convertedTrials / totalTrials : 0; + + return { totalTrials, convertedTrials, conversionRate }; + } + + convertTrial(trialId: string): BackendTrialConfig | undefined { + const config = this.trialConfigs.get(trialId); + if (!config) return undefined; + + config.status = TrialStatus.CONVERTED; + config.convertedAt = new Date().toISOString(); + config.updatedAt = new Date().toISOString(); + return config; + } + + expireTrial(trialId: string): BackendTrialConfig | undefined { + const config = this.trialConfigs.get(trialId); + if (!config) return undefined; + + config.status = TrialStatus.EXPIRED; + config.updatedAt = new Date().toISOString(); + return config; + } +} + +export const backendTrialService = new BackendTrialService(); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 0f32b2ee..27e5f95b 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -77,6 +77,7 @@ const PaymentMethodsScreen = lazyScreen(() => })) ); const AnalyticsDashboard = lazyScreen(() => import('../../app/screens/AnalyticsDashboard')); +const TrialDetailsScreen = lazyScreen(() => import('../screens/TrialDetailsScreen')); const Tab = createBottomTabNavigator(); const Stack = createNativeStackNavigator(); @@ -199,6 +200,11 @@ const HomeStack = () => ( component={IntegrationGuidesScreen} options={{ headerShown: false }} /> + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 10bf40b0..410a86c3 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -48,6 +48,7 @@ export type RootStackParamList = { ChangePlan: { subscriptionId: string }; PaymentMethods: undefined; AnalyticsDashboard: undefined; + TrialDetails: undefined; NotFound: { reason?: string }; }; diff --git a/src/screens/TrialDetailsScreen.tsx b/src/screens/TrialDetailsScreen.tsx new file mode 100644 index 00000000..5b81efe0 --- /dev/null +++ b/src/screens/TrialDetailsScreen.tsx @@ -0,0 +1,752 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + TextInput, +} from 'react-native'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { RootStackParamList } from '../navigation/types'; +import { useThemeColors } from '../hooks/useThemeColors'; +import { useTrialStore } from '../store'; +import { trialConfigService, abTestService, conversionTracker, reminderScheduler } from '../services/trialService'; +import { TrialStatus, TrialDuration, TrialFeatureAccess, PaymentRequirement, TrialReminder } from '../types/trial'; +import { FormScreen } from '../components/common/ScreenTemplates'; +import { Card } from '../components/common/Card'; +import { Button } from '../components/common/Button'; +import { spacing, typography, borderRadius } from '../utils/constants'; + +type TrialDetailsRouteProp = RouteProp; + +interface ConversionFunnelData { + eventType: string; + count: number; + percentage: number; +} + +export const TrialDetailsScreen: React.FC = () => { + const navigation = useNavigation>(); + const route = useRoute(); + const colors = useThemeColors(); + const styles = React.useMemo(() => createStyles(colors), [colors]); + const { trialConfigs, abTestAssignments, conversionFunnel, isLoading, error } = useTrialStore(); + + const [selectedTrial, setSelectedTrial] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + const [newTrialConfig, setNewTrialConfig] = useState({ + subscriptionId: '', + duration: TrialDuration.SEVEN_DAYS, + featureAccess: TrialFeatureAccess.FULL, + paymentRequirement: PaymentRequirement.REQUIRED, + abTestId: '', + }); + + useEffect(() => { + if (route.params?.trialId) { + setSelectedTrial(route.params.trialId); + } + }, [route.params]); + + const selectedTrialConfig = selectedTrial + ? trialConfigs.find((tc) => tc.id === selectedTrial) + : null; + + const getFunnelSteps = (trialId: string): ConversionFunnelData[] => { + const events = conversionTracker.getFunnelForTrial(trialId); + const steps: Record = {}; + events.forEach((e) => { + steps[e.eventType] = (steps[e.eventType] || 0) + 1; + }); + const total = events.length || 1; + return Object.entries(steps).map(([eventType, count]) => ({ + eventType, + count, + percentage: (count / total) * 100, + })); + }; + + const handleCreateTrial = async () => { + try { + await trialConfigService.create( + newTrialConfig.subscriptionId, + newTrialConfig.duration, + newTrialConfig.featureAccess, + newTrialConfig.paymentRequirement, + newTrialConfig.abTestId || undefined + ); + setShowCreateForm(false); + setNewTrialConfig({ + subscriptionId: '', + duration: TrialDuration.SEVEN_DAYS, + featureAccess: TrialFeatureAccess.FULL, + paymentRequirement: PaymentRequirement.REQUIRED, + abTestId: '', + }); + } catch { + // Error handled by service + } + }; + + const handleConvertTrial = async (trialId: string) => { + await useTrialStore.getState().convertTrial(trialId); + await conversionTracker.track({ + trialConfigId: trialId, + eventType: 'trial_converted', + userId: 'current-user', + }); + }; + + const handleExpireTrial = async (trialId: string) => { + await useTrialStore.getState().expireTrial(trialId); + await conversionTracker.track({ + trialConfigId: trialId, + eventType: 'trial_expired', + userId: 'current-user', + }); + }; + + const renderStatusBadge = (status: TrialStatus) => { + let backgroundColor = colors.border.default; + let textColor = colors.text; + + switch (status) { + case TrialStatus.ACTIVE: + backgroundColor = colors.primary; + textColor = colors.background; + break; + case TrialStatus.CONVERTED: + backgroundColor = colors.success; + textColor = colors.background; + break; + case TrialStatus.EXPIRED: + backgroundColor = colors.warning; + textColor = colors.background; + break; + case TrialStatus.CANCELLED: + backgroundColor = colors.error; + textColor = colors.background; + break; + } + + return ( + + {status.toUpperCase()} + + ); + }; + + const renderTrialCard = (trial: any) => ( + setSelectedTrial(trial.id)} + style={styles.trialCard} + > + + Trial {trial.id.substring(0, 8)} + {renderStatusBadge(trial.status)} + + + + Duration: {TrialDuration[trial.duration] || trial.duration} + + + Access: {TrialFeatureAccess[trial.featureAccess] || trial.featureAccess} + + + {trial.abTestId && ( + + A/B Test: {trial.abTestId} + + )} + + ); + + const renderFunnelChart = (steps: ConversionFunnelData[]) => { + if (steps.length === 0) { + return No funnel data available; + } + + const maxCount = Math.max(...steps.map((s) => s.count)); + + return ( + + {steps.map((step, index) => ( + + + {step.eventType.replace(/_/g, ' ')} + {step.count} + + + + + {step.percentage.toFixed(1)}% + + ))} + + ); + }; + + const renderVariantStats = () => { + const abTestId = selectedTrialConfig?.abTestId; + if (!abTestId) { + return No active A/B test for this trial; + } + + const assignments = abTestService.getAssignmentsForTest(abTestId); + const distribution = abTestService.getVariantDistribution(abTestId); + const stats = useTrialStore.getState().getConversionStats(abTestId); + + return ( + + A/B Test Results + + + {stats.totalTrials} + Total Trials + + + {(stats.conversionRate * 100).toFixed(1)}% + Conversion Rate + + + Variant Distribution + {Object.entries(distribution).map(([variant, count]) => ( + + {variant} + + + + {count} + + ))} + + ); + }; + + const renderReminders = () => { + const schedule = selectedTrialConfig + ? reminderScheduler.getByTrialConfigId(selectedTrial.id) + : undefined; + + if (!schedule) { + return ( + + No reminders scheduled + + ); + } + + return ( + + Reminder Schedule + {schedule.reminders.map((reminder: TrialReminder) => ( + + {reminder.type} + {reminder.sent ? 'Sent' : 'Pending'} + {reminder.message && {reminder.message}} + + ))} + + ); + }; + + return ( + + + {error && {error}} + + {/* Trial Configs List */} + + + Trial Configurations +