diff --git a/app/onboarding/navigation-flow.test.ts b/__tests__/onboarding/navigation-flow.test similarity index 100% rename from app/onboarding/navigation-flow.test.ts rename to __tests__/onboarding/navigation-flow.test diff --git a/app/onboarding/userType.integration.test.ts b/__tests__/onboarding/userType.integration.test.ts similarity index 100% rename from app/onboarding/userType.integration.test.ts rename to __tests__/onboarding/userType.integration.test.ts diff --git a/app/onboarding/userType.performance.test.ts b/__tests__/onboarding/userType.performance.test.ts similarity index 100% rename from app/onboarding/userType.performance.test.ts rename to __tests__/onboarding/userType.performance.test.ts diff --git a/app/onboarding/userType.recovery.test.ts b/__tests__/onboarding/userType.recovery.test.ts similarity index 100% rename from app/onboarding/userType.recovery.test.ts rename to __tests__/onboarding/userType.recovery.test.ts diff --git a/app/onboarding/_layout.tsx b/app/onboarding/_layout.tsx index e66ad4f6..e67df79d 100644 --- a/app/onboarding/_layout.tsx +++ b/app/onboarding/_layout.tsx @@ -1,16 +1,173 @@ // app/onboarding/_layout.tsx import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { useRouter } from 'expo-router'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +// Define the fallback component BEFORE the error boundary class that uses it +const OnboardingErrorFallback = React.memo(({ error, onRetry }: { error: Error | null; onRetry: () => void }) => { + const router = useRouter(); + + React.useEffect(() => { + console.error('[OnboardingErrorFallback] Rendering error fallback for:', error?.message); + }, [error]); + + const handleGoHome = React.useCallback(() => { + console.log('[OnboardingErrorFallback] Navigating to home'); + try { + router.replace('/'); + } catch (err) { + console.error('[OnboardingErrorFallback] Failed to navigate home:', err); + } + }, [router]); + + const handleRetry = React.useCallback(() => { + console.log('[OnboardingErrorFallback] Retrying onboarding'); + onRetry(); + }, [onRetry]); + + return ( + + Oops! Something went wrong + + We encountered an issue while loading the onboarding screen. + + {error && ( + + Error: {error.message} + + )} + + + + Try Again + + + + Go to Home + + + + + If this issue persists, please report it with this error message. + + + ); +}); + +class OnboardingErrorBoundary extends React.Component< + { children: React.ReactNode }, + ErrorBoundaryState +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('[OnboardingErrorBoundary] Error caught:', error); + console.error('[OnboardingErrorBoundary] Error info:', errorInfo); + } + + render() { + if (this.state.hasError) { + return this.setState({ hasError: false, error: null })} />; + } + + return this.props.children; + } +} export default function OnboardingLayout() { return ( - <> + + + + + - + ); -} \ No newline at end of file +} + +const styles = StyleSheet.create({ + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + backgroundColor: '#FFFFFF', + }, + errorTitle: { + fontSize: 24, + fontWeight: '700', + color: '#D32F2F', + marginBottom: 16, + textAlign: 'center', + }, + errorMessage: { + fontSize: 16, + color: '#424242', + textAlign: 'center', + marginBottom: 12, + lineHeight: 22, + }, + errorDetails: { + fontSize: 14, + color: '#757575', + textAlign: 'center', + marginBottom: 32, + fontFamily: 'monospace', + padding: 12, + backgroundColor: '#F5F5F5', + borderRadius: 8, + }, + buttonContainer: { + flexDirection: 'row', + gap: 16, + marginBottom: 32, + }, + retryButton: { + backgroundColor: '#007AFF', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + retryButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + homeButton: { + backgroundColor: '#34C759', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 8, + }, + homeButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + debugInfo: { + fontSize: 12, + color: '#9E9E9E', + textAlign: 'center', + fontStyle: 'italic', + }, +}); \ No newline at end of file diff --git a/app/onboarding/age.tsx b/app/onboarding/age.tsx index b0982abb..528cbd15 100644 --- a/app/onboarding/age.tsx +++ b/app/onboarding/age.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Alert, Modal, ScrollView } from 'react-native'; import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated'; import { useRouter } from 'expo-router'; @@ -22,6 +22,26 @@ export default function BirthDateCollection() { const [selectedYear, setSelectedYear] = useState(''); const [validationError, setValidationError] = useState(''); const [showSafetyModal, setShowSafetyModal] = useState(false); + const [isNavigating, setIsNavigating] = useState(false); + const [debugInfo, setDebugInfo] = useState([]); + + useEffect(() => { + console.log('[BirthDateCollection] Component mounted'); + setDebugInfo(['Birth date collection screen loaded']); + }, []); + + const addDebugInfo = useCallback((info: string) => { + console.log(`[BirthDateCollection] ${info}`); + setDebugInfo(prev => [...prev, `${new Date().toISOString()}: ${info}`]); + }, []); + + const showDebugInfo = useCallback(() => { + Alert.alert( + 'Debug Information', + debugInfo.slice(-10).join('\n'), + [{ text: 'OK' }] + ); + }, [debugInfo]); // Calculate if current selection is valid const isComplete = selectedMonth && selectedDay && selectedYear; @@ -62,7 +82,7 @@ export default function BirthDateCollection() { } }, [selectedDay, selectedMonth]); - const handleContinue = useCallback(() => { + const handleContinue = useCallback(async () => { if (!isComplete) { setValidationError('Please select your complete birth date'); return; @@ -73,37 +93,63 @@ export default function BirthDateCollection() { return; } - const age = calculateAge(birthDate); - - // Log analytics - logAnalyticsEvent(ANALYTICS_EVENTS.BIRTH_DATE_COLLECTION_COMPLETED, { - age, - birth_year: selectedYear, - birth_month: selectedMonth, - age_range: age < 18 ? 'minor' : age < 65 ? 'adult' : 'senior' - }); - - // Route based on age (same logic as before) - if (age < 18) { - // Route to child safety screen for minors - router.push({ - pathname: '/onboarding/child-safety', - params: { - age: age.toString(), - birthDate: birthDate - } - }); - } else { - // Route to demo for adults - router.push({ - pathname: '/onboarding/demo', - params: { - age: age.toString(), - birthDate: birthDate - } + try { + setIsNavigating(true); + setValidationError(''); + + const age = calculateAge(birthDate); + addDebugInfo(`User age calculated: ${age}`); + + // Log analytics + logAnalyticsEvent(ANALYTICS_EVENTS.BIRTH_DATE_COLLECTION_COMPLETED, { + age, + birth_year: selectedYear, + birth_month: selectedMonth, + age_range: age < 18 ? 'minor' : age < 65 ? 'adult' : 'senior' }); + + addDebugInfo(`Analytics logged for age: ${age}`); + + // Route based on age with error handling + if (age < 18) { + addDebugInfo('Routing to child safety screen (minor)'); + router.push({ + pathname: '/onboarding/child-safety', + params: { + age: age.toString(), + birthDate: birthDate + } + }); + } else { + addDebugInfo('Routing to demo screen (adult)'); + router.push({ + pathname: '/onboarding/demo', + params: { + age: age.toString(), + birthDate: birthDate + } + }); + } + + addDebugInfo('Navigation call completed successfully'); + + } catch (error) { + setIsNavigating(false); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + addDebugInfo(`Navigation error: ${errorMessage}`); + console.error('[BirthDateCollection] Navigation error:', error); + + Alert.alert( + 'Navigation Error', + `Failed to continue: ${errorMessage}\n\nWould you like to try again or report this issue?`, + [ + { text: 'Retry', onPress: () => handleContinue() }, + { text: 'Report Issue', onPress: () => showDebugInfo() }, + { text: 'Cancel', style: 'cancel' } + ] + ); } - }, [isComplete, isValid, validation, birthDate, selectedYear, selectedMonth, router]); + }, [isComplete, isValid, validation, birthDate, selectedYear, selectedMonth, router, addDebugInfo, isNavigating]); const handleSkip = useCallback(() => { // Show safety explanation modal instead of directly skipping @@ -233,10 +279,10 @@ export default function BirthDateCollection() { style={[ styles.continueButton, isMobileWeb && styles.continueButtonMobile, - !isValid && styles.continueButtonDisabled + (!isValid || isNavigating) && styles.continueButtonDisabled ]} onPress={handleContinue} - disabled={!isValid} + disabled={!isValid || isNavigating} accessibilityRole="button" accessibilityLabel="Continue with birth date" accessibilityHint="Proceed to next step" @@ -244,11 +290,11 @@ export default function BirthDateCollection() { - Continue + {isNavigating ? 'Loading...' : 'Continue'} - + {!isNavigating && } ([]); - const handleStart = useCallback(() => { - router.push('/onboarding/age'); - }, [router]); + useEffect(() => { + console.log('[Welcome] Component mounted'); + setDebugInfo(prev => [...prev, `Welcome screen loaded at ${new Date().toISOString()}`]); + }, []); + + const addDebugInfo = useCallback((info: string) => { + console.log(`[Welcome] ${info}`); + setDebugInfo(prev => [...prev, `${new Date().toISOString()}: ${info}`]); + }, []); + + const handleStart = useCallback(async () => { + try { + setIsNavigating(true); + addDebugInfo('User clicked Try Now button'); + + // Add a small delay to show loading state + await new Promise(resolve => setTimeout(resolve, 100)); + + addDebugInfo('Attempting navigation to /onboarding/age'); + router.push('/onboarding/age'); + addDebugInfo('Navigation call completed'); + + } catch (error) { + setIsNavigating(false); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + addDebugInfo(`Navigation error: ${errorMessage}`); + console.error('[Welcome] Navigation error:', error); + + Alert.alert( + 'Navigation Error', + `Failed to start onboarding: ${errorMessage}\n\nDebug info:\n${debugInfo.slice(-3).join('\n')}`, + [ + { text: 'Retry', onPress: () => handleStart() }, + { text: 'Report Issue', onPress: () => showDebugInfo() } + ] + ); + } + }, [router, addDebugInfo, debugInfo]); const handleImageError = useCallback(() => { + addDebugInfo('Main image failed to load, using fallback'); setImageError(true); - }, []); + }, [addDebugInfo]); + + const showDebugInfo = useCallback(() => { + Alert.alert( + 'Debug Information', + debugInfo.slice(-10).join('\n'), + [{ text: 'OK' }] + ); + }, [debugInfo]); return ( @@ -46,15 +92,33 @@ export default function Welcome() { - Try Now - + + {isNavigating ? 'Starting...' : 'Try Now'} + + {!isNavigating && } No account needed + + {/* Debug button for development */} + {__DEV__ && ( + + Debug Info ({debugInfo.length}) + + )} @@ -130,6 +194,21 @@ const styles = StyleSheet.create({ color: 'rgba(255, 255, 255, 0.8)', textAlign: 'center', }, + buttonDisabled: { + opacity: 0.7, + }, + debugButton: { + marginTop: 16, + paddingVertical: 8, + paddingHorizontal: 16, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 4, + }, + debugButtonText: { + color: 'rgba(255, 255, 255, 0.9)', + fontSize: 12, + textAlign: 'center', + }, // Mobile-specific styles contentMobile: { paddingHorizontal: 16, diff --git a/app/onboarding/protocol.tsx b/app/onboarding/protocol.tsx new file mode 100644 index 00000000..54ee06fa --- /dev/null +++ b/app/onboarding/protocol.tsx @@ -0,0 +1,862 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ScrollView, TextInput, Alert } from 'react-native'; +import Animated, { FadeIn, FadeInDown, FadeInRight } from 'react-native-reanimated'; +import { useRouter } from 'expo-router'; +import { Check, ArrowRight, ArrowLeft, Syringe, Pill, Activity, Plus } from 'lucide-react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useUserProfile } from '@/contexts/UserProfileContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { Protocol, ProtocolType, PROTOCOL_TEMPLATES, ProtocolTemplate } from '@/types/protocol'; +import { logAnalyticsEvent, ANALYTICS_EVENTS } from '@/lib/analytics'; +import { isMobileWeb } from '@/lib/utils'; + +interface ProtocolSetupData { + type: ProtocolType | null; + medication: string; + dosage: string; + unit: 'mg' | 'mL' | 'IU' | 'mcg'; + frequency: 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom'; + customFrequencyDays?: number; +} + +export default function ProtocolSetup() { + const router = useRouter(); + const { user } = useAuth(); + const { profile, saveProfile } = useUserProfile(); + const [currentStep, setCurrentStep] = useState(0); + const [isCompleting, setIsCompleting] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const [fieldErrors, setFieldErrors] = useState<{[key: string]: string}>({}); + const [setupData, setSetupData] = useState({ + type: null, + medication: '', + dosage: '', + unit: 'mg', + frequency: 'weekly', + }); + + // Log analytics when step starts + React.useEffect(() => { + logAnalyticsEvent(ANALYTICS_EVENTS.ONBOARDING_STEP_START, { + step: currentStep + 1, + step_name: getStepName(currentStep), + flow: 'protocol_setup' + }); + }, [currentStep]); + + const getStepName = (step: number): string => { + switch (step) { + case 0: return 'protocol_type_selection'; + case 1: return 'medication_details'; + default: return 'unknown'; + } + }; + + const handleProtocolTypeSelect = useCallback((type: ProtocolType) => { + setSetupData(prev => ({ ...prev, type })); + + // Log the selection + logAnalyticsEvent(ANALYTICS_EVENTS.ONBOARDING_STEP_COMPLETE, { + step: currentStep + 1, + step_name: getStepName(currentStep), + question: 'protocol_type', + answer: type, + flow: 'protocol_setup' + }); + }, [currentStep]); + + const handleNext = useCallback(() => { + if (currentStep < 1) { + setCurrentStep(currentStep + 1); + } else { + handleComplete(); + } + }, [currentStep]); + + const handleBack = useCallback(() => { + if (currentStep > 0) { + setCurrentStep(currentStep - 1); + } else { + // Go back to user type screen + router.back(); + } + }, [currentStep, router]); + + const handleSkipProtocol = useCallback(async () => { + try { + console.log('[ProtocolSetup] User chose to skip protocol setup'); + + // Update profile to indicate no protocol setup + if (profile) { + const updatedProfile = { + ...profile, + hasSetupProtocol: false, + }; + await saveProfile(updatedProfile); + } + + // Log skip event + logAnalyticsEvent('protocol_setup_skipped', { + flow: 'protocol_setup' + }); + + // Navigate to main app + router.replace('/(tabs)/new-dose'); + } catch (error) { + console.error('[ProtocolSetup] Error skipping protocol:', error); + router.replace('/(tabs)/new-dose'); + } + }, [profile, saveProfile, router]); + + const handleComplete = useCallback(async () => { + try { + setIsCompleting(true); + setErrorMessage(null); + console.log('[ProtocolSetup] Starting protocol completion...'); + + // Validate required fields + const validation = validateCurrentStep(); + if (!validation.isValid) { + setFieldErrors(validation.errors); + setErrorMessage('Please fix the errors below before continuing.'); + setIsCompleting(false); + return; + } + + // Clear any previous errors + setFieldErrors({}); + setErrorMessage(null); + + // Create protocol object + const protocol: Protocol = { + id: `protocol_${Date.now()}`, + name: PROTOCOL_TEMPLATES.find(t => t.type === setupData.type)?.name || 'Custom Protocol', + medication: setupData.medication.trim(), + dosage: setupData.dosage.trim(), + unit: setupData.unit, + frequency: setupData.frequency, + customFrequencyDays: setupData.customFrequencyDays, + startDate: new Date().toISOString(), + isActive: true, + dateCreated: new Date().toISOString(), + userId: user?.uid, + }; + + // Save protocol to storage + const existingProtocols = await AsyncStorage.getItem('userProtocols'); + const protocols = existingProtocols ? JSON.parse(existingProtocols) : []; + protocols.push(protocol); + await AsyncStorage.setItem('userProtocols', JSON.stringify(protocols)); + + // Update user profile to indicate protocol setup is complete + if (profile) { + const updatedProfile = { + ...profile, + hasSetupProtocol: true, + }; + await saveProfile(updatedProfile); + } + + // Log completion + logAnalyticsEvent('protocol_setup_complete', { + protocol_type: setupData.type, + medication: setupData.medication, + frequency: setupData.frequency, + unit: setupData.unit, + flow: 'protocol_setup' + }); + + console.log('[ProtocolSetup] Protocol setup completed successfully'); + + // Small delay to show completion state + await new Promise(resolve => setTimeout(resolve, 500)); + + // Navigate to main app + router.replace('/(tabs)/new-dose'); + } catch (error) { + console.error('[ProtocolSetup] Error completing protocol setup:', error); + + let errorMsg = 'An unexpected error occurred. Please try again.'; + if (error instanceof Error) { + if (error.message.includes('network') || error.message.includes('fetch')) { + errorMsg = 'Connection error. Please check your internet and try again.'; + } else if (error.message.includes('storage') || error.message.includes('quota')) { + errorMsg = 'Storage error. Please try clearing some space and try again.'; + } + } + + setErrorMessage(errorMsg); + setIsCompleting(false); + } + }, [setupData, user?.uid, profile, saveProfile, router, validateCurrentStep]); + + const validateCurrentStep = (): { isValid: boolean; errors: {[key: string]: string} } => { + const errors: {[key: string]: string} = {}; + + switch (currentStep) { + case 0: + if (!setupData.type) { + errors.type = 'Please select a protocol type'; + } + break; + case 1: + if (!setupData.medication.trim()) { + errors.medication = 'Medication name is required'; + } + if (!setupData.dosage.trim()) { + errors.dosage = 'Dosage amount is required'; + } else { + // Validate that dosage is a valid number + const dosageNum = parseFloat(setupData.dosage.trim()); + if (isNaN(dosageNum) || dosageNum <= 0) { + errors.dosage = 'Please enter a valid dosage amount'; + } + } + break; + } + + return { + isValid: Object.keys(errors).length === 0, + errors + }; + }; + + const isCurrentStepComplete = (): boolean => { + const validation = validateCurrentStep(); + return validation.isValid; + }; + + const getSelectedTemplate = (): ProtocolTemplate | null => { + return PROTOCOL_TEMPLATES.find(t => t.type === setupData.type) || null; + }; + + const renderProtocolTypeStep = () => ( + + Choose Your Protocol Type + + Select the type of medication protocol you're following for personalized guidance. + + + + {PROTOCOL_TEMPLATES.map((template) => { + const isSelected = setupData.type === template.type; + const IconComponent = template.type === 'trt' ? Activity : + template.type === 'peptides' ? Syringe : + template.type === 'insulin' ? Pill : Plus; + + return ( + handleProtocolTypeSelect(template.type)} + accessibilityRole="button" + accessibilityLabel={template.name} + > + + + {isSelected && } + + + {template.name} + + + {template.description} + + + ); + })} + + + ); + + const renderMedicationDetailsStep = () => { + const selectedTemplate = getSelectedTemplate(); + + return ( + + Protocol Details + + Enter your medication details to complete your {selectedTemplate?.name} protocol setup. + + + {/* Error Message Display */} + {errorMessage && ( + + {errorMessage} + + )} + + + {/* Medication Name */} + + Medication Name + { + setSetupData(prev => ({ ...prev, medication: text })); + // Clear error when user starts typing + if (fieldErrors.medication) { + setFieldErrors(prev => ({ ...prev, medication: '' })); + } + }} + placeholder={selectedTemplate?.commonMedications[0] || "Enter medication name"} + placeholderTextColor="#A1A1A1" + /> + {fieldErrors.medication && ( + {fieldErrors.medication} + )} + + + {/* Dosage */} + + Standard Dose + + { + setSetupData(prev => ({ ...prev, dosage: text })); + // Clear error when user starts typing + if (fieldErrors.dosage) { + setFieldErrors(prev => ({ ...prev, dosage: '' })); + } + }} + placeholder="0.5" + placeholderTextColor="#A1A1A1" + keyboardType="decimal-pad" + /> + + {selectedTemplate?.defaultUnits.map((unit) => ( + setSetupData(prev => ({ ...prev, unit }))} + > + + {unit} + + + ))} + + + {fieldErrors.dosage && ( + {fieldErrors.dosage} + )} + + + {/* Frequency */} + + Frequency + + {selectedTemplate?.commonFrequencies.map((freq) => ( + setSetupData(prev => ({ ...prev, frequency: freq }))} + > + + {freq.charAt(0).toUpperCase() + freq.slice(1)} + + + ))} + + + + + ); + }; + + const renderCurrentStep = () => { + switch (currentStep) { + case 0: return renderProtocolTypeStep(); + case 1: return renderMedicationDetailsStep(); + default: return null; + } + }; + + const getProgressWidth = () => { + return ((currentStep + 1) / 2) * 100; + }; + + return ( + + + + Set Up Your Protocol + + Step {currentStep + 1} of 2 + + + {/* Progress bar */} + + + + + + {Math.round(getProgressWidth())}% Complete + + + + + {renderCurrentStep()} + + + + + + + Back + + + + + Skip for now + + + + + {isCompleting ? 'Saving...' : (currentStep === 1 ? 'Complete' : 'Next')} + + {!isCompleting && ( + + )} + {isCompleting && ( + + {/* Simple loading indicator */} + + + )} + + + + + + Your protocol information is stored securely and only used to provide personalized guidance. + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingTop: 40, + paddingHorizontal: 24, + }, + header: { + marginBottom: 24, + alignItems: 'center', + }, + title: { + fontSize: 28, + fontWeight: '700', + color: '#000000', + textAlign: 'center', + marginBottom: 12, + }, + subtitle: { + fontSize: 17, + color: '#6B6B6B', + textAlign: 'center', + lineHeight: 24, + marginBottom: 12, + }, + progressContainer: { + width: '100%', + alignItems: 'center', + marginTop: 16, + }, + progressBackground: { + width: '100%', + height: 4, + backgroundColor: '#E5E5EA', + borderRadius: 2, + overflow: 'hidden', + }, + progressBar: { + height: '100%', + backgroundColor: '#007AFF', + borderRadius: 2, + }, + progressLabel: { + fontSize: 12, + color: '#6B6B6B', + marginTop: 8, + }, + stepContainer: { + marginBottom: 24, + }, + stepTitle: { + fontSize: 24, + fontWeight: '600', + color: '#000000', + textAlign: 'center', + marginBottom: 12, + }, + stepDescription: { + fontSize: 16, + color: '#6B6B6B', + textAlign: 'center', + lineHeight: 22, + marginBottom: 32, + }, + optionsContainer: { + gap: 16, + }, + protocolCard: { + backgroundColor: '#FFFFFF', + borderWidth: 2, + borderColor: '#E5E5EA', + borderRadius: 16, + padding: 20, + marginBottom: 12, + }, + protocolCardSelected: { + borderColor: '#007AFF', + backgroundColor: '#F7F9FF', + }, + protocolCardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + }, + protocolTitle: { + fontSize: 18, + fontWeight: '600', + color: '#000000', + marginBottom: 8, + }, + protocolTitleSelected: { + color: '#007AFF', + }, + protocolDescription: { + fontSize: 14, + color: '#6B6B6B', + lineHeight: 20, + }, + formContainer: { + gap: 24, + }, + fieldContainer: { + gap: 8, + }, + fieldLabel: { + fontSize: 16, + fontWeight: '600', + color: '#000000', + }, + textInput: { + borderWidth: 1, + borderColor: '#E5E5EA', + borderRadius: 12, + padding: 16, + fontSize: 16, + color: '#000000', + backgroundColor: '#FFFFFF', + }, + dosageContainer: { + flexDirection: 'row', + gap: 12, + alignItems: 'flex-start', + }, + dosageInput: { + flex: 1, + }, + unitPicker: { + flexDirection: 'row', + backgroundColor: '#F2F2F7', + borderRadius: 12, + padding: 4, + }, + unitOption: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 8, + }, + unitOptionSelected: { + backgroundColor: '#007AFF', + }, + unitOptionText: { + fontSize: 14, + fontWeight: '500', + color: '#6B6B6B', + }, + unitOptionTextSelected: { + color: '#FFFFFF', + }, + frequencyContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + frequencyOption: { + paddingHorizontal: 16, + paddingVertical: 10, + borderRadius: 20, + borderWidth: 1, + borderColor: '#E5E5EA', + backgroundColor: '#FFFFFF', + }, + frequencyOptionSelected: { + borderColor: '#007AFF', + backgroundColor: '#007AFF', + }, + frequencyOptionText: { + fontSize: 14, + fontWeight: '500', + color: '#6B6B6B', + }, + frequencyOptionTextSelected: { + color: '#FFFFFF', + }, + footer: { + padding: 24, + paddingTop: 16, + backgroundColor: '#FFFFFF', + borderTopWidth: 1, + borderTopColor: '#E5E5EA', + }, + buttonContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingVertical: 12, + paddingHorizontal: 16, + }, + backButtonText: { + fontSize: 16, + fontWeight: '500', + color: '#007AFF', + }, + rightButtons: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + skipButton: { + paddingVertical: 12, + paddingHorizontal: 16, + }, + skipButtonText: { + fontSize: 16, + fontWeight: '500', + color: '#6B6B6B', + }, + nextButton: { + backgroundColor: '#007AFF', + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 24, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + minHeight: 48, + }, + nextButtonDisabled: { + backgroundColor: '#E5E5EA', + }, + nextButtonText: { + color: '#FFFFFF', + fontSize: 16, + fontWeight: '600', + }, + nextButtonTextDisabled: { + color: '#A1A1A1', + }, + privacyText: { + fontSize: 12, + color: '#A1A1A1', + textAlign: 'center', + lineHeight: 16, + }, + // Mobile-specific styles + stepContainerMobile: { + marginBottom: 20, + }, + headerMobile: { + marginBottom: 20, + }, + titleMobile: { + fontSize: 24, + marginBottom: 8, + }, + subtitleMobile: { + fontSize: 16, + marginBottom: 8, + }, + stepTitleMobile: { + fontSize: 20, + marginBottom: 8, + }, + stepDescriptionMobile: { + fontSize: 14, + marginBottom: 24, + }, + scrollContentMobile: { + paddingHorizontal: 16, + paddingTop: 20, + }, + optionsContainerMobile: { + gap: 12, + }, + protocolCardMobile: { + padding: 16, + marginBottom: 8, + }, + protocolTitleMobile: { + fontSize: 16, + }, + protocolDescriptionMobile: { + fontSize: 13, + }, + formContainerMobile: { + gap: 20, + }, + fieldLabelMobile: { + fontSize: 14, + }, + textInputMobile: { + padding: 12, + fontSize: 14, + }, + footerMobile: { + padding: 16, + paddingTop: 12, + }, + buttonContainerMobile: { + marginBottom: 12, + }, + backButtonMobile: { + paddingVertical: 8, + paddingHorizontal: 12, + }, + backButtonTextMobile: { + fontSize: 14, + }, + skipButtonMobile: { + paddingVertical: 8, + paddingHorizontal: 12, + }, + skipButtonTextMobile: { + fontSize: 14, + }, + nextButtonMobile: { + paddingHorizontal: 20, + paddingVertical: 10, + minHeight: 42, + }, + nextButtonTextMobile: { + fontSize: 14, + }, + privacyTextMobile: { + fontSize: 11, + }, + // Error and loading styles + errorContainer: { + backgroundColor: '#FFF2F2', + borderWidth: 1, + borderColor: '#FF6B6B', + borderRadius: 8, + padding: 12, + marginBottom: 16, + }, + errorText: { + color: '#D63031', + fontSize: 14, + fontWeight: '500', + textAlign: 'center', + }, + textInputError: { + borderColor: '#FF6B6B', + borderWidth: 2, + }, + fieldErrorText: { + color: '#D63031', + fontSize: 12, + marginTop: 4, + marginLeft: 4, + }, + loadingSpinner: { + marginLeft: 8, + }, + loadingText: { + fontSize: 16, + }, +}); \ No newline at end of file diff --git a/app/onboarding/userType.tsx b/app/onboarding/userType.tsx index e0e00985..bad8ad32 100644 --- a/app/onboarding/userType.tsx +++ b/app/onboarding/userType.tsx @@ -16,7 +16,7 @@ export default function UserTypeSegmentation() { const { user } = useAuth(); const { saveProfile } = useUserProfile(); const { submitOnboardingIntent } = useOnboardingIntentStorage(); - const { age, limitedFunctionality, reason } = useLocalSearchParams<{ + const { age, limitedFunctionality } = useLocalSearchParams<{ age: string; limitedFunctionality: string; reason: string; @@ -31,6 +31,7 @@ export default function UserTypeSegmentation() { isRecoveryUse: null, age: age ? parseInt(age) : null, birthDate: null, + followsProtocol: null, }); // Log analytics when step starts @@ -45,7 +46,8 @@ export default function UserTypeSegmentation() { switch (step) { case 0: return 'background'; case 1: return 'use_type'; - case 2: return 'personal_use'; + case 2: return 'protocol_decision'; + case 3: return 'personal_use'; default: return 'unknown'; } }; @@ -108,26 +110,40 @@ export default function UserTypeSegmentation() { step_name: getStepName(currentStep) }); - if (currentStep === 2) { + if (currentStep === 3) { // For personal use question, set to null when skipped setAnswers(prev => ({ ...prev, isPersonalUse: null })); } // Move to next step or complete - if (currentStep < 2) { + if (currentStep < 3) { + // Special case: if user selected "I follow a protocol" in step 2, skip step 3 and go directly to completion + if (currentStep === 2 && answers.followsProtocol === true) { + console.log('[UserType] User selected protocol - skipping personal use step and completing directly'); + handleComplete(); + return; + } + setCurrentStep(currentStep + 1); } else { handleComplete(); } - }, [currentStep]); + }, [currentStep, answers.followsProtocol, handleComplete]); const handleNext = useCallback(() => { - if (currentStep < 2) { + if (currentStep < 3) { + // Special case: if user selected "I follow a protocol" in step 2, skip step 3 and go directly to completion + if (currentStep === 2 && answers.followsProtocol === true) { + console.log('[UserType] User selected protocol - skipping personal use step and completing directly'); + handleComplete(); + return; + } + setCurrentStep(currentStep + 1); } else { handleComplete(); } - }, [currentStep]); + }, [currentStep, answers.followsProtocol, handleComplete]); const handleBack = useCallback(() => { if (currentStep > 0) { @@ -140,6 +156,7 @@ export default function UserTypeSegmentation() { console.log('[UserType] ========== ONBOARDING COMPLETION START =========='); console.log('[UserType] Current answers:', answers); console.log('[UserType] Current user:', user?.uid || 'No user'); + console.log('[UserType] followsProtocol value:', answers.followsProtocol); // Submit onboarding intent data (for analytics and data collection) // This happens first and independently of profile saving @@ -162,6 +179,8 @@ export default function UserTypeSegmentation() { age: answers.age || undefined, // Include age if provided dateCreated: new Date().toISOString(), userId: user?.uid, + followsProtocol: answers.followsProtocol ?? false, + hasSetupProtocol: false, // Will be set to true after protocol setup }; console.log('[UserType] Created profile object:', profile); @@ -184,6 +203,7 @@ export default function UserTypeSegmentation() { isCosmeticUse: profile.isCosmeticUse, isPerformanceUse: profile.isPerformanceUse, isRecoveryUse: profile.isRecoveryUse, + followsProtocol: profile.followsProtocol, skipped_personal_use: answers.isPersonalUse === null, age: profile.age, age_range: profile.age ? (profile.age < 18 ? 'minor' : profile.age < 65 ? 'adult' : 'senior') : 'unknown' @@ -208,10 +228,15 @@ export default function UserTypeSegmentation() { parsedProfile: storedProfile ? JSON.parse(storedProfile) : null }); - // Navigate directly to intro screen instead of relying on index.tsx routing - console.log('[UserType] 🚀 NAVIGATING DIRECTLY TO INTRO - calling router.replace("/(tabs)/new-dose")'); - console.log('[UserType] ========== BYPASSING INDEX.TSX ROUTING =========='); - router.replace('/(tabs)/new-dose'); + // Navigate based on whether user wants to set up a protocol + if (answers.followsProtocol === true) { + console.log('[UserType] 🚀 NAVIGATING TO PROTOCOL SETUP - user wants to follow a protocol'); + router.replace('/onboarding/protocol'); + } else { + console.log('[UserType] 🚀 NAVIGATING DIRECTLY TO INTRO - user wants quick use mode'); + console.log('[UserType] ========== BYPASSING INDEX.TSX ROUTING =========='); + router.replace('/(tabs)/new-dose'); + } } catch (error) { console.error('[UserType] ❌ ERROR during completion:', error); console.error('[UserType] Error stack:', error instanceof Error ? error.stack : 'No stack'); @@ -225,7 +250,8 @@ export default function UserTypeSegmentation() { switch (currentStep) { case 0: return answers.isLicensedProfessional !== null || answers.isProfessionalAthlete !== null; case 1: return answers.isCosmeticUse !== null || answers.isPerformanceUse !== null || answers.isRecoveryUse !== null; - case 2: return true; // This step is always "complete" since it can be skipped + case 2: return answers.followsProtocol !== null; + case 3: return true; // This step is always "complete" since it can be skipped default: return false; } }; @@ -422,6 +448,63 @@ export default function UserTypeSegmentation() { ); + const renderProtocolDecisionStep = () => ( + + Are you following a protocol? + + This helps us determine whether to set up structured medication scheduling or provide quick one-off calculations. + + + + handleAnswerChange('followsProtocol', true)} + accessibilityRole="button" + accessibilityLabel="Yes, I follow a structured protocol" + > + {answers.followsProtocol === true && } + + Yes, I follow a protocol + + + I have a structured medication schedule (TRT, peptides, insulin, etc.) + + + + handleAnswerChange('followsProtocol', false)} + accessibilityRole="button" + accessibilityLabel="No, I need quick calculations" + > + {answers.followsProtocol === false && } + + No, quick calculations + + + I need one-off dose calculations and guidance + + + + + ); + const renderPersonalUseStep = () => ( Who is this for? @@ -492,13 +575,16 @@ export default function UserTypeSegmentation() { switch (currentStep) { case 0: return renderBackgroundStep(); case 1: return renderUseTypeStep(); - case 2: return renderPersonalUseStep(); + case 2: return renderProtocolDecisionStep(); + case 3: return renderPersonalUseStep(); default: return null; } }; const getProgressWidth = () => { - return ((currentStep + 1) / 3) * 100; + // If user selected protocol in step 2, we skip step 3, so total steps is 3 instead of 4 + const totalSteps = (currentStep >= 2 && answers.followsProtocol === true) ? 3 : 4; + return ((currentStep + 1) / totalSteps) * 100; }; return ( @@ -507,7 +593,7 @@ export default function UserTypeSegmentation() { Let's Personalize Your Experience - Step {currentStep + 1} of 3 + Step {currentStep + 1} of {(currentStep >= 2 && answers.followsProtocol === true) ? 3 : 4} {/* Progress bar */} diff --git a/types/protocol.ts b/types/protocol.ts new file mode 100644 index 00000000..abfa81f8 --- /dev/null +++ b/types/protocol.ts @@ -0,0 +1,69 @@ +export interface Protocol { + id: string; + name: string; + medication: string; + dosage: string; + unit: 'mg' | 'mL' | 'IU' | 'mcg'; + frequency: 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom'; + customFrequencyDays?: number; + startDate: string; // ISO 8601 date + isActive: boolean; + dateCreated: string; + userId?: string; +} + +export type ProtocolType = 'trt' | 'peptides' | 'insulin' | 'custom'; + +export interface ProtocolTemplate { + type: ProtocolType; + name: string; + description: string; + commonMedications: string[]; + defaultUnits: ('mg' | 'mL' | 'IU' | 'mcg')[]; + commonFrequencies: ('daily' | 'weekly' | 'biweekly' | 'monthly')[]; +} + +export const PROTOCOL_TEMPLATES: ProtocolTemplate[] = [ + { + type: 'trt', + name: 'Testosterone Replacement Therapy (TRT)', + description: 'Hormone replacement therapy for testosterone deficiency', + commonMedications: ['Testosterone Cypionate', 'Testosterone Enanthate', 'Testosterone Propionate'], + defaultUnits: ['mg', 'mL'], + commonFrequencies: ['weekly', 'biweekly'] + }, + { + type: 'peptides', + name: 'Peptides', + description: 'Peptide therapy for various health and wellness goals', + commonMedications: ['Semaglutide', 'Tirzepatide', 'BPC-157', 'TB-500', 'Ipamorelin'], + defaultUnits: ['mg', 'mcg', 'IU'], + commonFrequencies: ['daily', 'weekly'] + }, + { + type: 'insulin', + name: 'Insulin', + description: 'Insulin therapy for diabetes management', + commonMedications: ['Insulin Glargine', 'Insulin Lispro', 'Insulin Aspart', 'Insulin NPH'], + defaultUnits: ['IU', 'mL'], + commonFrequencies: ['daily'] + }, + { + type: 'custom', + name: 'Custom Protocol', + description: 'Create your own custom medication protocol', + commonMedications: [], + defaultUnits: ['mg', 'mL', 'IU', 'mcg'], + commonFrequencies: ['daily', 'weekly', 'biweekly', 'monthly'] + } +]; + +export interface ProtocolScheduleEntry { + id: string; + protocolId: string; + scheduledDate: string; // ISO 8601 date + isCompleted: boolean; + completedDate?: string; + actualDose?: string; + notes?: string; +} \ No newline at end of file diff --git a/types/userProfile.ts b/types/userProfile.ts index d1fa8308..d6add087 100644 --- a/types/userProfile.ts +++ b/types/userProfile.ts @@ -9,6 +9,8 @@ export interface UserProfile { birthDate?: string; // Birth date in YYYY-MM-DD format for more precise age calculation dateCreated: string; userId?: string; // Optional field to track which user this profile belongs to + followsProtocol: boolean; // Whether user follows a structured medication protocol + hasSetupProtocol?: boolean; // Whether user has completed protocol setup } export type UserProfileAnswers = { @@ -20,6 +22,7 @@ export type UserProfileAnswers = { isRecoveryUse: boolean | null; age: number | null; birthDate: string | null; // Birth date in YYYY-MM-DD format + followsProtocol: boolean | null; // Whether user follows a structured medication protocol }; export enum WarningLevel {