diff --git a/app/(auth)/_layout.tsx b/app/(auth)/_layout.tsx index 9ac3ffe..2a9a3fc 100644 --- a/app/(auth)/_layout.tsx +++ b/app/(auth)/_layout.tsx @@ -10,6 +10,10 @@ export default function AuthLayout() { }} > + + + + ); } diff --git a/app/(auth)/activate.tsx b/app/(auth)/activate.tsx new file mode 100644 index 0000000..bd62cf4 --- /dev/null +++ b/app/(auth)/activate.tsx @@ -0,0 +1,479 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + TextInput, + ScrollView, + ActivityIndicator, + Alert, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme'; + +// DEV MODE: Mock activation code +const MOCK_ACTIVATION_CODE = 'WELLNUO-DEMO'; + +export default function ActivateScreen() { + const [activationCode, setActivationCode] = useState(''); + const [isActivating, setIsActivating] = useState(false); + const [step, setStep] = useState<'code' | 'beneficiary' | 'complete'>('code'); + const [beneficiaryName, setBeneficiaryName] = useState(''); + const [relationship, setRelationship] = useState(''); + + const handleActivate = async () => { + if (!activationCode.trim()) { + Alert.alert('Error', 'Please enter activation code'); + return; + } + + setIsActivating(true); + + // DEV MODE: Simulate activation + await new Promise((resolve) => setTimeout(resolve, 1500)); + + // Accept any code in dev mode, or use the mock code + if (activationCode.toUpperCase() === MOCK_ACTIVATION_CODE || activationCode.length >= 6) { + setStep('beneficiary'); + } else { + Alert.alert('Invalid Code', 'Please check your activation code and try again.\n\nDEV: Use "WELLNUO-DEMO" or any 6+ character code.'); + } + + setIsActivating(false); + }; + + const handleAddBeneficiary = async () => { + if (!beneficiaryName.trim()) { + Alert.alert('Error', 'Please enter beneficiary name'); + return; + } + + setIsActivating(true); + + // DEV MODE: Simulate adding beneficiary + await new Promise((resolve) => setTimeout(resolve, 1000)); + + setStep('complete'); + setIsActivating(false); + }; + + const handleComplete = () => { + // Navigate to main app + router.replace('/(tabs)'); + }; + + const handleUseDemoCode = () => { + setActivationCode(MOCK_ACTIVATION_CODE); + }; + + // Step 1: Enter activation code + if (step === 'code') { + return ( + + + {/* Header */} + + router.back()}> + + + Activate Kit + + + + {/* Icon */} + + + + + {/* Instructions */} + + Enter the activation code from your WellNuo Starter Kit packaging + + + {/* Input */} + + + + + {/* Development Notice */} + + + Sensor Activation In Development + + Real sensor pairing is coming soon. For now, enter any code (6+ characters) to continue testing the app. + + + Use Demo Code + + + + {/* Activate Button */} + + {isActivating ? ( + + ) : ( + Activate + )} + + + {/* Skip for now */} + + Skip for now + + + + ); + } + + // Step 2: Add beneficiary + if (step === 'beneficiary') { + return ( + + + {/* Header */} + + setStep('code')}> + + + Add Beneficiary + + + + {/* Icon */} + + + + + {/* Instructions */} + + Who will you be monitoring with this kit? + + + {/* Name Input */} + + Full Name + + + + {/* Relationship Input */} + + Relationship (optional) + + + + {/* Relationship Quick Select */} + + {['Mother', 'Father', 'Grandmother', 'Grandfather'].map((rel) => ( + setRelationship(rel)} + > + + {rel} + + + ))} + + + {/* Continue Button */} + + {isActivating ? ( + + ) : ( + Continue + )} + + + + ); + } + + // Step 3: Complete + return ( + + + {/* Success Icon */} + + + + + + Kit Activated! + + Your WellNuo kit has been successfully activated for{' '} + {beneficiaryName} + + + {/* Next Steps */} + + Next Steps: + + 1 + Place sensors in your loved one's home + + + 2 + Connect the hub to WiFi + + + 3 + Start receiving activity updates + + + + + {/* Complete Button */} + + Go to Dashboard + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: AppColors.background, + }, + content: { + flex: 1, + padding: Spacing.lg, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: Spacing.xl, + }, + backButton: { + padding: Spacing.sm, + marginLeft: -Spacing.sm, + }, + title: { + fontSize: FontSizes.xl, + fontWeight: FontWeights.bold, + color: AppColors.textPrimary, + }, + placeholder: { + width: 40, + }, + iconContainer: { + alignItems: 'center', + marginBottom: Spacing.xl, + }, + instructions: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.xl, + lineHeight: 24, + }, + inputContainer: { + marginBottom: Spacing.lg, + }, + inputGroup: { + marginBottom: Spacing.lg, + }, + inputLabel: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.medium, + color: AppColors.textPrimary, + marginBottom: Spacing.sm, + }, + input: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.lg, + paddingHorizontal: Spacing.lg, + paddingVertical: Spacing.md, + fontSize: FontSizes.lg, + color: AppColors.textPrimary, + textAlign: 'center', + borderWidth: 1, + borderColor: AppColors.border, + }, + devNotice: { + alignItems: 'center', + marginBottom: Spacing.xl, + paddingVertical: Spacing.lg, + paddingHorizontal: Spacing.lg, + backgroundColor: `${AppColors.warning}10`, + borderRadius: BorderRadius.lg, + borderWidth: 1, + borderColor: `${AppColors.warning}30`, + }, + devNoticeTitle: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.warning, + marginTop: Spacing.sm, + marginBottom: Spacing.sm, + }, + devNoticeText: { + fontSize: FontSizes.sm, + color: AppColors.textSecondary, + textAlign: 'center', + lineHeight: 20, + marginBottom: Spacing.md, + }, + demoCodeButton: { + paddingVertical: Spacing.sm, + paddingHorizontal: Spacing.lg, + backgroundColor: AppColors.warning, + borderRadius: BorderRadius.md, + }, + demoCodeButtonText: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.medium, + color: AppColors.white, + }, + primaryButton: { + backgroundColor: AppColors.primary, + paddingVertical: Spacing.lg, + borderRadius: BorderRadius.lg, + alignItems: 'center', + marginTop: Spacing.md, + }, + buttonDisabled: { + opacity: 0.7, + }, + primaryButtonText: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.white, + }, + skipButton: { + alignItems: 'center', + paddingVertical: Spacing.lg, + }, + skipButtonText: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textDecorationLine: 'underline', + }, + quickSelect: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: Spacing.sm, + marginBottom: Spacing.xl, + }, + quickSelectButton: { + paddingHorizontal: Spacing.md, + paddingVertical: Spacing.sm, + borderRadius: BorderRadius.full, + backgroundColor: AppColors.surface, + borderWidth: 1, + borderColor: AppColors.border, + }, + quickSelectButtonActive: { + backgroundColor: AppColors.primary, + borderColor: AppColors.primary, + }, + quickSelectText: { + fontSize: FontSizes.sm, + color: AppColors.textSecondary, + }, + quickSelectTextActive: { + color: AppColors.white, + fontWeight: FontWeights.medium, + }, + successContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + successIcon: { + marginBottom: Spacing.xl, + }, + successTitle: { + fontSize: FontSizes['2xl'], + fontWeight: FontWeights.bold, + color: AppColors.textPrimary, + marginBottom: Spacing.md, + }, + successMessage: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.xxl, + }, + beneficiaryHighlight: { + fontWeight: FontWeights.bold, + color: AppColors.primary, + }, + nextSteps: { + width: '100%', + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.lg, + padding: Spacing.lg, + }, + nextStepsTitle: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: Spacing.md, + }, + stepItem: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.md, + marginBottom: Spacing.sm, + }, + stepNumber: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: AppColors.primary, + color: AppColors.white, + textAlign: 'center', + lineHeight: 24, + fontSize: FontSizes.sm, + fontWeight: FontWeights.bold, + overflow: 'hidden', + }, + stepText: { + fontSize: FontSizes.sm, + color: AppColors.textSecondary, + flex: 1, + }, +}); diff --git a/app/(auth)/purchase.tsx b/app/(auth)/purchase.tsx new file mode 100644 index 0000000..9d23ed4 --- /dev/null +++ b/app/(auth)/purchase.tsx @@ -0,0 +1,312 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + ActivityIndicator, + Alert, + Linking, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme'; +import { useAuth } from '@/contexts/AuthContext'; + +// Stripe Checkout API +const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe'; + +const STARTER_KIT = { + name: 'WellNuo Starter Kit', + price: '$249', + priceValue: 249, + description: 'Everything you need to start monitoring your loved ones', + features: [ + 'Motion sensor (PIR)', + 'Door/window sensor', + 'Temperature & humidity sensor', + 'WellNuo Hub', + 'Mobile app access', + '1 year subscription included', + ], +}; + +export default function PurchaseScreen() { + const [isProcessing, setIsProcessing] = useState(false); + const { user } = useAuth(); + + const handlePurchase = async () => { + setIsProcessing(true); + + try { + // Create Stripe Checkout session via our API + const response = await fetch(`${STRIPE_API_URL}/create-checkout-session`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + userId: user?.user_id || 'guest', + email: user?.email, + beneficiaryName: 'To be configured', + beneficiaryAddress: 'To be configured', + includePremium: false, // Just starter kit for now + }), + }); + + const data = await response.json(); + + if (data.url) { + // Open Stripe Checkout in browser + const canOpen = await Linking.canOpenURL(data.url); + if (canOpen) { + await Linking.openURL(data.url); + // After returning from browser, go to activate + Alert.alert( + 'Complete Your Purchase', + 'After completing payment in the browser, tap "Continue" to activate your kit.', + [ + { + text: 'Continue', + onPress: () => router.replace('/(auth)/activate'), + }, + ] + ); + } else { + Alert.alert('Error', 'Could not open payment page'); + } + } else { + Alert.alert('Error', data.error || 'Failed to create checkout session'); + } + } catch (error) { + console.error('Stripe checkout error:', error); + Alert.alert('Error', 'Failed to connect to payment service. Please try again.'); + } + + setIsProcessing(false); + }; + + const handleSkip = () => { + // For users who already have a kit + router.replace('/(auth)/activate'); + }; + + return ( + + + {/* Header */} + + router.back()}> + + + Get Started + + + + {/* Product Card */} + + + + + {STARTER_KIT.name} + {STARTER_KIT.price} + {STARTER_KIT.description} + + {/* Features */} + + {STARTER_KIT.features.map((feature, index) => ( + + + {feature} + + ))} + + + + {/* Security Badge */} + + + + Secure payment powered by Stripe + + + + {/* Test Mode Notice */} + + + + Test mode: Use card 4242 4242 4242 4242 + + + + + {/* Bottom Actions */} + + + {isProcessing ? ( + + ) : ( + <> + + Buy Now - {STARTER_KIT.price} + + )} + + + + I already have a kit + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: AppColors.background, + }, + content: { + padding: Spacing.lg, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: Spacing.xl, + }, + backButton: { + padding: Spacing.sm, + marginLeft: -Spacing.sm, + }, + title: { + fontSize: FontSizes.xl, + fontWeight: FontWeights.bold, + color: AppColors.textPrimary, + }, + placeholder: { + width: 40, + }, + productCard: { + backgroundColor: AppColors.white, + borderRadius: BorderRadius.xl, + padding: Spacing.xl, + alignItems: 'center', + borderWidth: 2, + borderColor: AppColors.primary, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 5, + }, + productIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: `${AppColors.primary}15`, + alignItems: 'center', + justifyContent: 'center', + marginBottom: Spacing.lg, + }, + productName: { + fontSize: FontSizes['2xl'], + fontWeight: FontWeights.bold, + color: AppColors.textPrimary, + textAlign: 'center', + marginBottom: Spacing.sm, + }, + productPrice: { + fontSize: FontSizes['3xl'], + fontWeight: FontWeights.bold, + color: AppColors.primary, + marginBottom: Spacing.sm, + }, + productDescription: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.xl, + }, + features: { + width: '100%', + gap: Spacing.md, + }, + featureRow: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.sm, + }, + featureText: { + fontSize: FontSizes.base, + color: AppColors.textPrimary, + flex: 1, + }, + securityBadge: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.sm, + marginTop: Spacing.xl, + paddingVertical: Spacing.md, + backgroundColor: `${AppColors.success}10`, + borderRadius: BorderRadius.lg, + }, + securityText: { + fontSize: FontSizes.sm, + color: AppColors.success, + }, + testNotice: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.sm, + marginTop: Spacing.md, + paddingVertical: Spacing.sm, + paddingHorizontal: Spacing.md, + backgroundColor: `${AppColors.primary}10`, + borderRadius: BorderRadius.md, + }, + testNoticeText: { + fontSize: FontSizes.xs, + color: AppColors.primary, + fontWeight: FontWeights.medium, + }, + bottomActions: { + padding: Spacing.lg, + paddingBottom: Spacing.xl, + gap: Spacing.md, + }, + purchaseButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: Spacing.sm, + backgroundColor: AppColors.primary, + paddingVertical: Spacing.lg, + borderRadius: BorderRadius.lg, + }, + buttonDisabled: { + opacity: 0.7, + }, + purchaseButtonText: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.white, + }, + skipButton: { + alignItems: 'center', + paddingVertical: Spacing.md, + }, + skipButtonText: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textDecorationLine: 'underline', + }, +}); diff --git a/app/(auth)/verify-otp.tsx b/app/(auth)/verify-otp.tsx index e008ba1..7b2591b 100644 --- a/app/(auth)/verify-otp.tsx +++ b/app/(auth)/verify-otp.tsx @@ -43,7 +43,10 @@ export default function VerifyOTPScreen() { try { const success = await verifyOtp(email!, '000000'); // Bypass code if (success) { - router.replace('/(tabs)'); + // Dev account (serter2069@gmail.com) goes straight to dashboard (has data) + // Other accounts are new - send to purchase flow + const isDevEmail = email?.toLowerCase() === 'serter2069@gmail.com'; + router.replace(isDevEmail ? '/(tabs)' : '/(auth)/purchase'); } else { setError('Auto-login failed'); } @@ -98,14 +101,20 @@ export default function VerifyOTPScreen() { const success = await verifyOtp(email!, codeToVerify); if (success) { - router.replace('/(tabs)'); + // Dev account (serter2069@gmail.com) goes straight to dashboard (has data) + // Other accounts are new - send to purchase flow + const isDevEmail = email?.toLowerCase() === 'serter2069@gmail.com'; + router.replace(isDevEmail ? '/(tabs)' : '/(auth)/purchase'); } else { - setError(authError?.message || 'Invalid code. Please try again.'); - setCode(''); + // Show error but keep user on this screen to retry + setError('Invalid verification code. Please try again.'); + setCode(''); // Clear code so user can re-enter + setTimeout(() => inputRef.current?.focus(), 100); // Re-focus input for retry } } catch (err) { setError('Something went wrong. Please try again.'); setCode(''); + setTimeout(() => inputRef.current?.focus(), 100); } finally { setIsLoading(false); } diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 1a14824..b4cfe46 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -78,6 +78,13 @@ export default function TabLayout() { href: null, }} /> + {/* Hide voice tab - replaced by chat */} + ); } diff --git a/backend/.env.example b/backend/.env.example index e9e2965..5f7926d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,7 +13,7 @@ NODE_ENV=production # Brevo Email BREVO_API_KEY=your-brevo-key -BREVO_SENDER_EMAIL=noreply@wellnuo.com +BREVO_SENDER_EMAIL=noreply@daterabbit.com BREVO_SENDER_NAME=WellNuo # Frontend URL (for password reset links) diff --git a/contexts/AuthContext.tsx b/contexts/AuthContext.tsx index 11ceaf8..4d868df 100644 --- a/contexts/AuthContext.tsx +++ b/contexts/AuthContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback, typ import { api, setOnUnauthorizedCallback } from '@/services/api'; import type { User, ApiError } from '@/types'; -// Test account for development +// Test account for development - uses legacy anandk credentials const DEV_EMAIL = 'serter2069@gmail.com'; interface AuthState { @@ -92,17 +92,28 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { success: true, skipOtp: true }; } - // For now, we'll just succeed - real OTP sending would happen via backend - // In production, this would call: await api.requestOTP(email); - setState((prev) => ({ ...prev, isLoading: false })); - return { success: true, skipOtp: false }; + // Send OTP via Brevo API + const response = await api.requestOTP(email); + + if (response.ok) { + setState((prev) => ({ ...prev, isLoading: false })); + return { success: true, skipOtp: false }; + } + + // API failed + setState((prev) => ({ + ...prev, + isLoading: false, + error: { message: 'Failed to send verification code. Please try again.' }, + })); + return { success: false, skipOtp: false }; } catch (error) { setState((prev) => ({ ...prev, isLoading: false, - error: { message: error instanceof Error ? error.message : 'Failed to send OTP' }, + error: { message: 'Network error. Please check your connection.' }, })); - return { success: false }; + return { success: false, skipOtp: false }; } }, []); @@ -145,13 +156,33 @@ export function AuthProvider({ children }: { children: ReactNode }) { return false; } - // For regular users - verify OTP via backend - // In production: const response = await api.verifyOTP(email, code); - // For now, fail as we don't have real OTP backend yet + // Verify OTP via API + const verifyResponse = await api.verifyOTP(email, code); + + if (verifyResponse.ok && verifyResponse.data) { + const user: User = { + user_id: verifyResponse.data.user.id, + user_name: verifyResponse.data.user.first_name || email.split('@')[0], + email: email, + max_role: 'USER', + privileges: [], + }; + + setState({ + user, + isLoading: false, + isAuthenticated: true, + error: null, + }); + + return true; + } + + // Wrong OTP code setState((prev) => ({ ...prev, isLoading: false, - error: { message: 'OTP verification not implemented yet. Use dev account.' }, + error: { message: 'Invalid verification code. Please try again.' }, })); return false; } catch (error) { diff --git a/services/api.ts b/services/api.ts index 541f16d..6fa52d1 100644 --- a/services/api.ts +++ b/services/api.ts @@ -12,6 +12,10 @@ export function setOnUnauthorizedCallback(callback: () => void) { const API_BASE_URL = 'https://eluxnetworks.net/function/well-api/api'; const CLIENT_ID = 'MA_001'; +// WellNuo Backend API (our own API for auth, OTP, etc.) +// TODO: Update to production URL when deployed +const WELLNUO_API_URL = 'https://wellnuo.smartlaunchhub.com/api'; + // Avatar images for elderly beneficiaries - grandmothers (бабушки) const ELDERLY_AVATARS = [ 'https://images.unsplash.com/photo-1566616213894-2d4e1baee5d8?w=200&h=200&fit=crop&crop=face', // grandmother with gray hair @@ -162,6 +166,84 @@ class ApiService { } } + // Save mock user (for dev mode OTP flow) + async saveMockUser(user: { user_id: string; user_name: string; email: string; max_role: string; privileges: string[] }): Promise { + await SecureStore.setItemAsync('accessToken', `mock-token-${user.user_id}`); + await SecureStore.setItemAsync('userId', user.user_id); + await SecureStore.setItemAsync('userName', user.user_name); + await SecureStore.setItemAsync('privileges', user.privileges.join(',')); + await SecureStore.setItemAsync('maxRole', user.max_role); + await SecureStore.setItemAsync('userEmail', user.email); + } + + // ==================== OTP Authentication (WellNuo Backend) ==================== + + // Request OTP code - sends email via Brevo + async requestOTP(email: string): Promise> { + try { + const response = await fetch(`${WELLNUO_API_URL}/auth/request-otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email }), + }); + + const data = await response.json(); + + if (response.ok) { + return { data, ok: true }; + } + + return { + ok: false, + error: { message: data.error || 'Failed to send OTP' }, + }; + } catch (error) { + return { + ok: false, + error: { message: 'Network error. Please check your connection.' }, + }; + } + } + + // Verify OTP code and get JWT token + async verifyOTP(email: string, code: string): Promise> { + try { + const response = await fetch(`${WELLNUO_API_URL}/auth/verify-otp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, code }), + }); + + const data = await response.json(); + + if (response.ok && data.token) { + // Save auth data + await SecureStore.setItemAsync('accessToken', data.token); + await SecureStore.setItemAsync('userId', data.user.id); + await SecureStore.setItemAsync('userName', data.user.first_name || email.split('@')[0]); + await SecureStore.setItemAsync('userEmail', email); + await SecureStore.setItemAsync('maxRole', 'USER'); + await SecureStore.setItemAsync('privileges', ''); + + return { data, ok: true }; + } + + return { + ok: false, + error: { message: data.error || 'Invalid or expired code' }, + }; + } catch (error) { + return { + ok: false, + error: { message: 'Network error. Please check your connection.' }, + }; + } + } + async isAuthenticated(): Promise { const token = await this.getToken(); return !!token;