Add Stripe checkout, OTP auth improvements, navigation updates

- Add purchase screen with real Stripe Checkout integration
- Add kit activation screen with dev mode notice
- Remove mock OTP mode - only serter2069@gmail.com bypasses OTP
- Fix OTP retry - show error without redirecting to email screen
- Update tab navigation: Dashboard, Chat, Profile (hide Voice)
- Update Brevo sender email to daterabbit.com domain

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sergei 2025-12-24 13:44:10 -08:00
parent 9475890d5a
commit c80fd4ab4b
8 changed files with 940 additions and 16 deletions

View File

@ -10,6 +10,10 @@ export default function AuthLayout() {
}}
>
<Stack.Screen name="login" />
<Stack.Screen name="verify-otp" />
<Stack.Screen name="purchase" />
<Stack.Screen name="activate" />
<Stack.Screen name="complete-profile" />
</Stack>
);
}

479
app/(auth)/activate.tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.title}>Activate Kit</Text>
<View style={styles.placeholder} />
</View>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="qr-code" size={64} color={AppColors.primary} />
</View>
{/* Instructions */}
<Text style={styles.instructions}>
Enter the activation code from your WellNuo Starter Kit packaging
</Text>
{/* Input */}
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
value={activationCode}
onChangeText={setActivationCode}
placeholder="WELLNUO-XXXX-XXXX"
placeholderTextColor={AppColors.textMuted}
autoCapitalize="characters"
autoCorrect={false}
/>
</View>
{/* Development Notice */}
<View style={styles.devNotice}>
<Ionicons name="construct" size={20} color={AppColors.warning} />
<Text style={styles.devNoticeTitle}>Sensor Activation In Development</Text>
<Text style={styles.devNoticeText}>
Real sensor pairing is coming soon. For now, enter any code (6+ characters) to continue testing the app.
</Text>
<TouchableOpacity style={styles.demoCodeButton} onPress={handleUseDemoCode}>
<Text style={styles.demoCodeButtonText}>Use Demo Code</Text>
</TouchableOpacity>
</View>
{/* Activate Button */}
<TouchableOpacity
style={[styles.primaryButton, isActivating && styles.buttonDisabled]}
onPress={handleActivate}
disabled={isActivating}
>
{isActivating ? (
<ActivityIndicator color={AppColors.white} />
) : (
<Text style={styles.primaryButtonText}>Activate</Text>
)}
</TouchableOpacity>
{/* Skip for now */}
<TouchableOpacity style={styles.skipButton} onPress={handleComplete}>
<Text style={styles.skipButtonText}>Skip for now</Text>
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
}
// Step 2: Add beneficiary
if (step === 'beneficiary') {
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => setStep('code')}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.title}>Add Beneficiary</Text>
<View style={styles.placeholder} />
</View>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="person-add" size={64} color={AppColors.primary} />
</View>
{/* Instructions */}
<Text style={styles.instructions}>
Who will you be monitoring with this kit?
</Text>
{/* Name Input */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Full Name</Text>
<TextInput
style={styles.input}
value={beneficiaryName}
onChangeText={setBeneficiaryName}
placeholder="e.g., Grandma Julia"
placeholderTextColor={AppColors.textMuted}
/>
</View>
{/* Relationship Input */}
<View style={styles.inputGroup}>
<Text style={styles.inputLabel}>Relationship (optional)</Text>
<TextInput
style={styles.input}
value={relationship}
onChangeText={setRelationship}
placeholder="e.g., Mother, Father, Grandmother"
placeholderTextColor={AppColors.textMuted}
/>
</View>
{/* Relationship Quick Select */}
<View style={styles.quickSelect}>
{['Mother', 'Father', 'Grandmother', 'Grandfather'].map((rel) => (
<TouchableOpacity
key={rel}
style={[
styles.quickSelectButton,
relationship === rel && styles.quickSelectButtonActive,
]}
onPress={() => setRelationship(rel)}
>
<Text
style={[
styles.quickSelectText,
relationship === rel && styles.quickSelectTextActive,
]}
>
{rel}
</Text>
</TouchableOpacity>
))}
</View>
{/* Continue Button */}
<TouchableOpacity
style={[styles.primaryButton, isActivating && styles.buttonDisabled]}
onPress={handleAddBeneficiary}
disabled={isActivating}
>
{isActivating ? (
<ActivityIndicator color={AppColors.white} />
) : (
<Text style={styles.primaryButtonText}>Continue</Text>
)}
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
}
// Step 3: Complete
return (
<SafeAreaView style={styles.container}>
<View style={styles.content}>
{/* Success Icon */}
<View style={styles.successContainer}>
<View style={styles.successIcon}>
<Ionicons name="checkmark-circle" size={80} color={AppColors.success} />
</View>
<Text style={styles.successTitle}>Kit Activated!</Text>
<Text style={styles.successMessage}>
Your WellNuo kit has been successfully activated for{' '}
<Text style={styles.beneficiaryHighlight}>{beneficiaryName}</Text>
</Text>
{/* Next Steps */}
<View style={styles.nextSteps}>
<Text style={styles.nextStepsTitle}>Next Steps:</Text>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>1</Text>
<Text style={styles.stepText}>Place sensors in your loved one's home</Text>
</View>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>2</Text>
<Text style={styles.stepText}>Connect the hub to WiFi</Text>
</View>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>3</Text>
<Text style={styles.stepText}>Start receiving activity updates</Text>
</View>
</View>
</View>
{/* Complete Button */}
<TouchableOpacity style={styles.primaryButton} onPress={handleComplete}>
<Text style={styles.primaryButtonText}>Go to Dashboard</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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,
},
});

312
app/(auth)/purchase.tsx Normal file
View File

@ -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 (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.title}>Get Started</Text>
<View style={styles.placeholder} />
</View>
{/* Product Card */}
<View style={styles.productCard}>
<View style={styles.productIcon}>
<Ionicons name="hardware-chip" size={48} color={AppColors.primary} />
</View>
<Text style={styles.productName}>{STARTER_KIT.name}</Text>
<Text style={styles.productPrice}>{STARTER_KIT.price}</Text>
<Text style={styles.productDescription}>{STARTER_KIT.description}</Text>
{/* Features */}
<View style={styles.features}>
{STARTER_KIT.features.map((feature, index) => (
<View key={index} style={styles.featureRow}>
<Ionicons name="checkmark-circle" size={20} color={AppColors.success} />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
</View>
{/* Security Badge */}
<View style={styles.securityBadge}>
<Ionicons name="shield-checkmark" size={20} color={AppColors.success} />
<Text style={styles.securityText}>
Secure payment powered by Stripe
</Text>
</View>
{/* Test Mode Notice */}
<View style={styles.testNotice}>
<Ionicons name="card" size={16} color={AppColors.primary} />
<Text style={styles.testNoticeText}>
Test mode: Use card 4242 4242 4242 4242
</Text>
</View>
</ScrollView>
{/* Bottom Actions */}
<View style={styles.bottomActions}>
<TouchableOpacity
style={[styles.purchaseButton, isProcessing && styles.buttonDisabled]}
onPress={handlePurchase}
disabled={isProcessing}
>
{isProcessing ? (
<ActivityIndicator color={AppColors.white} />
) : (
<>
<Ionicons name="card" size={20} color={AppColors.white} />
<Text style={styles.purchaseButtonText}>Buy Now - {STARTER_KIT.price}</Text>
</>
)}
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipButtonText}>I already have a kit</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
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',
},
});

View File

@ -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);
}

View File

@ -78,6 +78,13 @@ export default function TabLayout() {
href: null,
}}
/>
{/* Hide voice tab - replaced by chat */}
<Tabs.Screen
name="voice"
options={{
href: null,
}}
/>
</Tabs>
);
}

View File

@ -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)

View File

@ -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);
// 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) {

View File

@ -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<void> {
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<ApiResponse<{ message: string }>> {
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<ApiResponse<{ token: string; user: { id: string; email: string; first_name?: string; last_name?: string } }>> {
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<boolean> {
const token = await this.getToken();
return !!token;