WIP: Navigation controller, subscription flow, and various improvements

- Add NavigationController for centralized routing logic
- Add useNavigationFlow hook for easy usage in components
- Update subscription flow with Stripe integration
- Simplify activate.tsx
- Update beneficiaries and profile screens
- Update CLAUDE.md with navigation documentation
This commit is contained in:
Sergei 2026-01-04 12:53:38 -08:00
parent 1d93311b12
commit 20be9a94c2
10 changed files with 1354 additions and 527 deletions

136
CLAUDE.md
View File

@ -12,7 +12,7 @@
- Every CRUD operation goes through the WellNuo API - Every CRUD operation goes through the WellNuo API
2. **Single Source of Truth: WellNuo Backend** 2. **Single Source of Truth: WellNuo Backend**
- All beneficiary data is stored in Supabase via the backend - All beneficiary data is stored in PostgreSQL (hosted on eluxnetworks.net)
- Changes are immediately persisted to the server - Changes are immediately persisted to the server
- App always fetches fresh data on screen focus - App always fetches fresh data on screen focus
@ -28,10 +28,11 @@ Base URL: `https://wellnuo.smartlaunchhub.com/api`
| POST | `/me/beneficiaries` | Create new beneficiary | | POST | `/me/beneficiaries` | Create new beneficiary |
| PATCH | `/me/beneficiaries/:id` | Update beneficiary | | PATCH | `/me/beneficiaries/:id` | Update beneficiary |
| DELETE | `/me/beneficiaries/:id` | Remove beneficiary access | | DELETE | `/me/beneficiaries/:id` | Remove beneficiary access |
| GET | `/me/profile` | Get current user profile | | POST | `/me/beneficiaries/:id/activate` | Activate device for beneficiary |
| PATCH | `/me/profile` | Update user profile (firstName, lastName, phone) | | GET | `/auth/me` | Get current user profile with beneficiaries |
| PATCH | `/auth/profile` | Update user profile (firstName, lastName, phone) |
Authentication: Bearer token (JWT from `/auth/login`) Authentication: Bearer token (JWT from `/auth/verify-otp`)
#### Legacy API (WebView Dashboard) #### Legacy API (WebView Dashboard)
Base URL: `https://eluxnetworks.net/function/well-api/api` Base URL: `https://eluxnetworks.net/function/well-api/api`
@ -43,10 +44,10 @@ Used only for:
### Data Flow ### Data Flow
``` ```
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────────────┐ ┌──────────────────
│ React App │ ──▶ │ WellNuo Backend │ ──▶ │ Supabase │ React App │ ──▶ │ WellNuo Backend │ ──▶ │ PostgreSQL
│ (Expo) │ ◀── │ (Express.js) │ ◀── │ (Postgres) │ (Expo) │ ◀── │ (Express.js) │ ◀── │ (eluxnetworks.net)
└─────────────┘ └──────────────────┘ └─────────────┘ └─────────────┘ └──────────────────┘ └──────────────────
``` ```
### Database Schema ### Database Schema
@ -69,12 +70,127 @@ Used only for:
### What TO do ### What TO do
- ✅ Always fetch fresh data via `api.getAllBeneficiaries()` or `api.getWellNuoBeneficiary(id)` - ✅ Always fetch fresh data via `api.getAllBeneficiaries()` or `api.getWellNuoBeneficiary(id)`
- ✅ Get user profile via `api.getProfile()`, update via `api.updateProfile()` - ✅ Get user profile via `api.getMe()`, update via `api.updateProfile()` (calls `/auth/profile`)
- ✅ Use `useFocusEffect` to reload data when screen gains focus - ✅ Use `useFocusEffect` to reload data when screen gains focus
- ✅ Handle loading and error states for all API calls - ✅ Handle loading and error states for all API calls
- ✅ Show appropriate feedback on successful operations - ✅ Show appropriate feedback on successful operations
- ✅ Only store in SecureStore: `accessToken`, `userId`, `userEmail` (technical auth data) - ✅ Only store in SecureStore: `accessToken`, `userId`, `userEmail` (technical auth data)
## Navigation Controller (Centralized Routing)
All navigation decisions are centralized in `services/NavigationController.ts`.
**IMPORTANT: NavigationController is NOT automatic middleware!**
- It does NOT intercept or control all navigation automatically
- It's a utility that must be EXPLICITLY called at specific points in the app
- You must manually call `nav.navigateAfterLogin()`, `nav.navigateAfterAddBeneficiary()`, etc.
- Regular navigation (e.g., `router.push('/profile')`) works independently
### When to use NavigationController:
- ✅ After OTP verification (login flow)
- ✅ After creating a new beneficiary
- ✅ After completing purchase/demo selection
- ✅ When you need to determine the "correct next screen" based on user state
### When NOT needed:
- ❌ Simple screen-to-screen navigation (just use `router.push()`)
- ❌ Tab navigation (handled by Expo Router)
- ❌ Back navigation (handled automatically)
### Key Files
- `services/NavigationController.ts` - All routing logic and conditions
- `hooks/useNavigationFlow.ts` - React hook for easy usage in components
### Navigation Flow After Login
```
User enters OTP code
Has firstName in profile?
NO → /(auth)/enter-name
YES → Has beneficiaries?
NO → /(auth)/add-loved-one
YES → Check equipment status of first beneficiary
├── none → /(auth)/purchase
├── ordered/shipped → /(tabs)/beneficiaries/:id/equipment
├── delivered → /(auth)/activate
└── active/demo → /(tabs)/dashboard
```
### Navigation After Adding Beneficiary
```
Beneficiary created in DB
User already has WellNuo devices?
NO → /(auth)/purchase (buy equipment)
YES → /(auth)/activate (connect existing device)
```
### Navigation After Purchase
```
Payment completed / Demo selected
Demo mode?
YES → /(auth)/activate (immediate activation)
NO → /(tabs)/beneficiaries/:id/equipment (track delivery)
```
### Usage Example
```typescript
import { useNavigationFlow } from '@/hooks/useNavigationFlow';
function MyScreen() {
const nav = useNavigationFlow();
// After login - automatically determines correct screen
nav.navigateAfterLogin(profile, beneficiaries);
// After adding beneficiary
nav.navigateAfterAddBeneficiary(42, hasExistingDevices);
// After purchase
nav.navigateAfterPurchase(42, { demo: true });
// Quick shortcuts
nav.goToDashboard();
nav.goToPurchase(beneficiaryId);
nav.goToActivate(beneficiaryId);
}
```
### Available Routes (ROUTES constant)
```typescript
ROUTES.AUTH.LOGIN // /(auth)/login
ROUTES.AUTH.VERIFY_OTP // /(auth)/verify-otp
ROUTES.AUTH.ENTER_NAME // /(auth)/enter-name
ROUTES.AUTH.ADD_LOVED_ONE // /(auth)/add-loved-one
ROUTES.AUTH.PURCHASE // /(auth)/purchase
ROUTES.AUTH.ACTIVATE // /(auth)/activate
ROUTES.TABS.DASHBOARD // /(tabs)/dashboard
ROUTES.TABS.BENEFICIARIES // /(tabs)/beneficiaries
ROUTES.TABS.CHAT // /(tabs)/chat
ROUTES.TABS.VOICE // /(tabs)/voice
ROUTES.TABS.PROFILE // /(tabs)/profile
ROUTES.BENEFICIARY.DETAIL(id) // /(tabs)/beneficiaries/:id
ROUTES.BENEFICIARY.SUBSCRIPTION(id) // /(tabs)/beneficiaries/:id/subscription
ROUTES.BENEFICIARY.EQUIPMENT(id) // /(tabs)/beneficiaries/:id/equipment
ROUTES.BENEFICIARY.SHARE(id) // /(tabs)/beneficiaries/:id/share
```
## Development ## Development
### Server Location ### Server Location
@ -83,6 +199,8 @@ Used only for:
### Key Files ### Key Files
- `services/api.ts` - All API methods - `services/api.ts` - All API methods
- `services/NavigationController.ts` - Centralized routing logic
- `hooks/useNavigationFlow.ts` - Navigation hook for components
- `contexts/BeneficiaryContext.tsx` - Beneficiary state management (current selection only) - `contexts/BeneficiaryContext.tsx` - Beneficiary state management (current selection only)
- `app/(tabs)/beneficiaries/` - Beneficiary screens - `app/(tabs)/beneficiaries/` - Beneficiary screens

View File

@ -13,27 +13,19 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme'; import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { api } from '@/services/api'; import { api } from '@/services/api';
export default function ActivateScreen() { export default function ActivateScreen() {
// Get params - lovedOneName from purchase flow, beneficiaryId from existing beneficiary // Get params - beneficiaryId is REQUIRED (created in add-loved-one.tsx)
// lovedOneName is for display purposes
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>(); const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
const beneficiaryId = params.beneficiaryId ? parseInt(params.beneficiaryId, 10) : null;
const lovedOneName = params.lovedOneName || '';
const [activationCode, setActivationCode] = useState(''); const [activationCode, setActivationCode] = useState('');
const [isActivating, setIsActivating] = useState(false); const [isActivating, setIsActivating] = useState(false);
const [step, setStep] = useState<'code' | 'beneficiary' | 'complete'>('code'); const [step, setStep] = useState<'code' | 'complete'>('code');
// Pre-fill beneficiary name from params if available
const [beneficiaryName, setBeneficiaryName] = useState(params.lovedOneName || '');
const { localBeneficiaries } = useBeneficiary();
// Check if we're activating for an existing beneficiary
const existingBeneficiaryId = params.beneficiaryId ? parseInt(params.beneficiaryId, 10) : null;
const existingBeneficiary = existingBeneficiaryId
? localBeneficiaries.find(b => b.id === existingBeneficiaryId)
: null;
// Demo serial for testing without real hardware // Demo serial for testing without real hardware
const DEMO_SERIAL = 'DEMO-00000'; const DEMO_SERIAL = 'DEMO-00000';
@ -55,49 +47,26 @@ export default function ActivateScreen() {
return; return;
} }
// Beneficiary ID is required - was created in add-loved-one.tsx
if (!beneficiaryId) {
Alert.alert('Error', 'Beneficiary not found. Please go back and try again.');
return;
}
setIsActivating(true); setIsActivating(true);
try { try {
// If we have an existing beneficiary, activate via API (persists to server!) // Call API to activate - sets has_existing_devices = true on backend
if (existingBeneficiaryId && existingBeneficiary) { const response = await api.activateBeneficiary(beneficiaryId, code);
// Call API to persist activation - this creates order with 'installed' status
const response = await api.activateBeneficiary(existingBeneficiaryId, code);
if (!response.ok) { if (!response.ok) {
Alert.alert('Error', response.error?.message || 'Failed to activate equipment'); Alert.alert('Error', response.error?.message || 'Failed to activate equipment');
return; return;
} }
setBeneficiaryName(existingBeneficiary.name); // Mark onboarding as completed
setStep('complete');
} else {
// Creating new beneficiary - If name was already provided from add-loved-one screen, create via API
if (params.lovedOneName && params.lovedOneName.trim()) {
// Step 1: Create beneficiary on server
const createResult = await api.createBeneficiary({
firstName: params.lovedOneName.trim(),
});
if (!createResult.ok || !createResult.data) {
Alert.alert('Error', createResult.error?.message || 'Failed to create beneficiary');
return;
}
// Step 2: Activate the newly created beneficiary (sets has_existing_devices = true)
const activateResult = await api.activateBeneficiary(createResult.data.id, code);
if (!activateResult.ok) {
Alert.alert('Error', activateResult.error?.message || 'Failed to activate equipment');
return;
}
await api.setOnboardingCompleted(true); await api.setOnboardingCompleted(true);
setStep('complete'); setStep('complete');
} else {
// No name provided, show beneficiary form
setStep('beneficiary');
}
}
} catch (error) { } catch (error) {
console.error('Failed to activate:', error); console.error('Failed to activate:', error);
Alert.alert('Error', 'Failed to activate kit. Please try again.'); Alert.alert('Error', 'Failed to activate kit. Please try again.');
@ -106,52 +75,11 @@ export default function ActivateScreen() {
} }
}; };
const handleAddBeneficiary = async () => {
if (!beneficiaryName.trim()) {
Alert.alert('Error', 'Please enter beneficiary name');
return;
}
setIsActivating(true);
try {
const code = activationCode.trim().toUpperCase();
// Step 1: Create beneficiary on server
const createResult = await api.createBeneficiary({
firstName: beneficiaryName.trim(),
});
if (!createResult.ok || !createResult.data) {
Alert.alert('Error', createResult.error?.message || 'Failed to create beneficiary');
return;
}
// Step 2: Activate the newly created beneficiary (sets has_existing_devices = true)
const activateResult = await api.activateBeneficiary(createResult.data.id, code);
if (!activateResult.ok) {
Alert.alert('Error', activateResult.error?.message || 'Failed to activate equipment');
return;
}
// Mark onboarding as completed
await api.setOnboardingCompleted(true);
setStep('complete');
} catch (error) {
console.error('Failed to add beneficiary:', error);
Alert.alert('Error', 'Failed to add beneficiary. Please try again.');
} finally {
setIsActivating(false);
}
};
const handleComplete = () => { const handleComplete = () => {
// If updating existing beneficiary, go back to their detail page // Navigate to beneficiary detail page after activation
if (existingBeneficiaryId) { if (beneficiaryId) {
router.replace(`/(tabs)/beneficiaries/${existingBeneficiaryId}`); router.replace(`/(tabs)/beneficiaries/${beneficiaryId}` as const);
} else { } else {
// Navigate to main app
router.replace('/(tabs)'); router.replace('/(tabs)');
} }
}; };
@ -163,16 +91,10 @@ export default function ActivateScreen() {
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled"> <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* Header */} {/* Header */}
<View style={styles.header}> <View style={styles.header}>
{existingBeneficiary ? (
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}> <TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} /> <Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity> </TouchableOpacity>
) : ( <Text style={styles.title}>Connect Sensors</Text>
<View style={styles.placeholder} />
)}
<Text style={styles.title}>
{existingBeneficiary ? 'Connect Sensors' : 'Activate Kit'}
</Text>
<View style={styles.placeholder} /> <View style={styles.placeholder} />
</View> </View>
@ -183,9 +105,9 @@ export default function ActivateScreen() {
{/* Instructions */} {/* Instructions */}
<Text style={styles.instructions}> <Text style={styles.instructions}>
{existingBeneficiary {lovedOneName
? `Connect sensors for ${existingBeneficiary.name}` ? `Connect sensors for ${lovedOneName}`
: 'Enter the activation code from your WellNuo Starter Kit packaging'} : 'Enter the serial number from your sensors'}
</Text> </Text>
{/* Input */} {/* Input */}
@ -222,76 +144,12 @@ export default function ActivateScreen() {
<Text style={styles.primaryButtonText}>Activate</Text> <Text style={styles.primaryButtonText}>Activate</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
{/* Skip for now - only show for new onboarding, not for existing beneficiary */}
{!existingBeneficiary && (
<TouchableOpacity style={styles.skipButton} onPress={handleComplete}>
<Text style={styles.skipButtonText}>Skip for now</Text>
</TouchableOpacity>
)}
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
// Step 2: Add beneficiary // Step 2: Complete
if (step === 'beneficiary') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<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}
autoCapitalize="words"
autoCorrect={false}
/>
</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
const displayName = beneficiaryName || existingBeneficiary?.name || params.lovedOneName;
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}> <View style={styles.content}>
@ -301,32 +159,15 @@ export default function ActivateScreen() {
<Ionicons name="checkmark-circle" size={80} color={AppColors.success} /> <Ionicons name="checkmark-circle" size={80} color={AppColors.success} />
</View> </View>
<Text style={styles.successTitle}> <Text style={styles.successTitle}>Sensors Connected!</Text>
{existingBeneficiary ? 'Sensors Connected!' : 'Kit Activated!'}
</Text>
<Text style={styles.successMessage}> <Text style={styles.successMessage}>
{existingBeneficiary Your sensors have been successfully connected for{' '}
? `Sensors have been connected for ` <Text style={styles.beneficiaryHighlight}>{lovedOneName || 'your loved one'}</Text>
: `Your WellNuo kit has been successfully activated for `}
<Text style={styles.beneficiaryHighlight}>{displayName}</Text>
</Text> </Text>
{/* Next Steps */} {/* Next Steps */}
<View style={styles.nextSteps}> <View style={styles.nextSteps}>
<Text style={styles.nextStepsTitle}>Next Steps:</Text> <Text style={styles.nextStepsTitle}>Next Steps:</Text>
{existingBeneficiary ? (
<>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>1</Text>
<Text style={styles.stepText}>Connect the hub to WiFi</Text>
</View>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>2</Text>
<Text style={styles.stepText}>Subscribe to start monitoring ($49/month)</Text>
</View>
</>
) : (
<>
<View style={styles.stepItem}> <View style={styles.stepItem}>
<Text style={styles.stepNumber}>1</Text> <Text style={styles.stepNumber}>1</Text>
<Text style={styles.stepText}>Place sensors in your loved one's home</Text> <Text style={styles.stepText}>Place sensors in your loved one's home</Text>
@ -339,16 +180,12 @@ export default function ActivateScreen() {
<Text style={styles.stepNumber}>3</Text> <Text style={styles.stepNumber}>3</Text>
<Text style={styles.stepText}>Subscribe to start monitoring ($49/month)</Text> <Text style={styles.stepText}>Subscribe to start monitoring ($49/month)</Text>
</View> </View>
</>
)}
</View> </View>
</View> </View>
{/* Complete Button */} {/* Complete Button */}
<TouchableOpacity style={styles.primaryButton} onPress={handleComplete}> <TouchableOpacity style={styles.primaryButton} onPress={handleComplete}>
<Text style={styles.primaryButtonText}> <Text style={styles.primaryButtonText}>Go to Dashboard</Text>
{existingBeneficiary ? 'Continue' : 'Go to Dashboard'}
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</SafeAreaView> </SafeAreaView>
@ -396,15 +233,6 @@ const styles = StyleSheet.create({
inputContainer: { inputContainer: {
marginBottom: Spacing.lg, marginBottom: Spacing.lg,
}, },
inputGroup: {
marginBottom: Spacing.lg,
},
inputLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.medium,
color: AppColors.textPrimary,
marginBottom: Spacing.sm,
},
input: { input: {
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg, borderRadius: BorderRadius.lg,
@ -416,41 +244,6 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderColor: AppColors.border, 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,
},
demoCodeLink: { demoCodeLink: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -477,15 +270,6 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold, fontWeight: FontWeights.semibold,
color: AppColors.white, color: AppColors.white,
}, },
skipButton: {
alignItems: 'center',
paddingVertical: Spacing.lg,
},
skipButtonText: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textDecorationLine: 'underline',
},
successContainer: { successContainer: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',

View File

@ -48,11 +48,18 @@ type SetupState = 'loading' | 'awaiting_equipment' | 'no_devices' | 'no_subscrip
// Stripe API // Stripe API
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe'; const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
// WebView Dashboard URL - uses test NDK account for demo data // WebView Dashboard URL - opens specific deployment directly
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard'; // Format: /dashboard/{deployment_id} skips the user list and shows the dashboard
const getDashboardUrl = (deploymentId?: number) => {
const baseUrl = 'https://react.eluxnetworks.net/dashboard';
// Default to Ferdinand's deployment (21) if no specific deployment
return deploymentId ? `${baseUrl}/${deploymentId}` : `${baseUrl}/21`;
};
// Test credentials for WebView - anandk account has real sensor data // Test credentials for WebView - anandk account has real sensor data
const TEST_NDK_USER = 'anandk'; const TEST_NDK_USER = 'anandk';
const TEST_NDK_PASSWORD = 'anandk_8'; const TEST_NDK_PASSWORD = 'anandk_8';
// Ferdinand's default deployment ID (has sensor data)
const FERDINAND_DEPLOYMENT_ID = 21;
// Starter Kit info // Starter Kit info
const STARTER_KIT = { const STARTER_KIT = {
@ -697,7 +704,7 @@ export default function BeneficiaryDetailScreen() {
isWebViewReady && authToken ? ( isWebViewReady && authToken ? (
<WebView <WebView
ref={webViewRef} ref={webViewRef}
source={{ uri: DASHBOARD_URL }} source={{ uri: getDashboardUrl(FERDINAND_DEPLOYMENT_ID) }}
style={styles.webView} style={styles.webView}
javaScriptEnabled={true} javaScriptEnabled={true}
domStorageEnabled={true} domStorageEnabled={true}

View File

@ -15,6 +15,7 @@ import { usePaymentSheet } from '@stripe/stripe-react-native';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { api } from '@/services/api';
import type { Beneficiary, BeneficiarySubscription } from '@/types'; import type { Beneficiary, BeneficiarySubscription } from '@/types';
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe'; const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
@ -48,7 +49,7 @@ export default function SubscriptionScreen() {
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet(); const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth(); const { user } = useAuth();
const { getBeneficiaryById, updateLocalBeneficiary } = useBeneficiary(); const { updateLocalBeneficiary } = useBeneficiary();
useEffect(() => { useEffect(() => {
loadBeneficiary(); loadBeneficiary();
@ -58,8 +59,12 @@ export default function SubscriptionScreen() {
if (!id) return; if (!id) return;
try { try {
const data = await getBeneficiaryById(id); const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
setBeneficiary(data); if (response.ok && response.data) {
setBeneficiary(response.data);
} else {
console.error('Failed to load beneficiary:', response.error);
}
} catch (error) { } catch (error) {
console.error('Failed to load beneficiary:', error); console.error('Failed to load beneficiary:', error);
} finally { } finally {

View File

@ -10,39 +10,8 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { PageHeader } from '@/components/PageHeader'; import { PageHeader } from '@/components/PageHeader';
interface InfoRowProps {
label: string;
value: string;
}
function InfoRow({ label, value }: InfoRowProps) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
</View>
);
}
interface LinkRowProps {
icon: keyof typeof Ionicons.glyphMap;
title: string;
onPress: () => void;
}
function LinkRow({ icon, title, onPress }: LinkRowProps) {
return (
<TouchableOpacity style={styles.linkRow} onPress={onPress}>
<Ionicons name={icon} size={20} color={AppColors.primary} />
<Text style={styles.linkText}>{title}</Text>
<Ionicons name="open-outline" size={16} color={AppColors.textMuted} />
</TouchableOpacity>
);
}
export default function AboutScreen() { export default function AboutScreen() {
const openURL = (url: string) => { const openURL = (url: string) => {
Linking.openURL(url).catch(() => {}); Linking.openURL(url).catch(() => {});
@ -50,255 +19,228 @@ export default function AboutScreen() {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<PageHeader title="About WellNuo" /> <PageHeader title="About" />
<ScrollView showsVerticalScrollIndicator={false}> <ScrollView
{/* App Logo & Name */} showsVerticalScrollIndicator={false}
<View style={styles.heroSection}> contentContainerStyle={styles.scrollContent}
>
{/* Header */}
<View style={styles.header}>
<Image <Image
source={require('@/assets/images/icon.png')} source={require('@/assets/logo.png')}
style={styles.logoImage} style={styles.logo}
resizeMode="contain" resizeMode="contain"
/> />
<Text style={styles.appTagline}>Caring for Those Who Matter Most</Text> <Text style={styles.appName}>WellNuo</Text>
</View> <Text style={styles.tagline}>Caring for those who matter most</Text>
<Text style={styles.version}>Version 1.0.0</Text>
{/* Version Info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>App Information</Text>
<View style={styles.card}>
<InfoRow label="Version" value="1.0.0" />
<View style={styles.infoDivider} />
<InfoRow label="Build" value="2024.12.001" />
<View style={styles.infoDivider} />
<InfoRow label="Platform" value="iOS / Android" />
<View style={styles.infoDivider} />
<InfoRow label="SDK" value="Expo SDK 54" />
<View style={styles.infoDivider} />
<InfoRow label="Last Updated" value="December 2024" />
</View>
</View> </View>
{/* Description */} {/* Description */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>About</Text>
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.description}> <Text style={styles.cardText}>
WellNuo is a comprehensive elderly care monitoring application designed to help WellNuo helps families stay connected with their loved ones through
families and caregivers stay connected with their loved ones. Using advanced smart monitoring and AI-powered insights.
sensor technology and AI-powered analytics, WellNuo provides real-time insights
into daily activities, health patterns, and emergency situations.
</Text> </Text>
<Text style={styles.description}>
Our mission is to bring peace of mind to families while preserving the independence
and dignity of elderly individuals.
</Text>
</View>
</View> </View>
{/* Features */} {/* Features */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Key Features</Text> <Text style={styles.sectionTitle}>Features</Text>
<View style={styles.card}> <View style={styles.card}>
<View style={styles.featureItem}> <FeatureItem
<View style={[styles.featureIcon, { backgroundColor: '#DBEAFE' }]}> icon="pulse"
<Ionicons name="pulse" size={20} color="#3B82F6" /> title="Activity Monitoring"
</View> subtitle="24/7 wellness tracking"
<View style={styles.featureContent}> />
<Text style={styles.featureTitle}>Real-time Monitoring</Text> <FeatureItem
<Text style={styles.featureDescription}>24/7 activity and wellness tracking</Text> icon="notifications"
</View> title="Smart Alerts"
</View> subtitle="Instant emergency notifications"
<View style={styles.featureItem}> />
<View style={[styles.featureIcon, { backgroundColor: '#FEE2E2' }]}> <FeatureItem
<Ionicons name="warning" size={20} color="#EF4444" /> icon="bar-chart"
</View> title="Health Insights"
<View style={styles.featureContent}> subtitle="AI-powered pattern analysis"
<Text style={styles.featureTitle}>Emergency Alerts</Text> />
<Text style={styles.featureDescription}>Instant notifications for falls and emergencies</Text> <FeatureItem
</View> icon="people"
</View> title="Family Sharing"
<View style={styles.featureItem}> subtitle="Coordinate care together"
<View style={[styles.featureIcon, { backgroundColor: '#D1FAE5' }]}> last
<Ionicons name="analytics" size={20} color="#10B981" /> />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>AI-Powered Insights</Text>
<Text style={styles.featureDescription}>Smart analysis of health patterns</Text>
</View>
</View>
<View style={styles.featureItem}>
<View style={[styles.featureIcon, { backgroundColor: '#FEF3C7' }]}>
<Ionicons name="people" size={20} color="#F59E0B" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>Family Coordination</Text>
<Text style={styles.featureDescription}>Share care with multiple caregivers</Text>
</View>
</View>
</View> </View>
</View> </View>
{/* Links */} {/* Links */}
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>Resources</Text> <Text style={styles.sectionTitle}>Links</Text>
<View style={styles.card}> <View style={styles.card}>
<LinkRow <LinkItem
icon="globe-outline" title="Website"
title="Visit Website"
onPress={() => openURL('https://wellnuo.com')} onPress={() => openURL('https://wellnuo.com')}
/> />
<View style={styles.linkDivider} /> <LinkItem
<LinkRow title="Help Center"
icon="document-text-outline"
title="Documentation"
onPress={() => openURL('https://docs.wellnuo.com')} onPress={() => openURL('https://docs.wellnuo.com')}
/> />
<View style={styles.linkDivider} /> <LinkItem
<LinkRow title="Privacy Policy"
icon="logo-twitter" onPress={() => openURL('https://wellnuo.com/privacy')}
title="Follow on Twitter" />
onPress={() => openURL('https://twitter.com/wellnuo')} <LinkItem
title="Terms of Service"
onPress={() => openURL('https://wellnuo.com/terms')}
last
/> />
</View> </View>
</View> </View>
{/* Footer */} {/* Footer */}
<View style={styles.footer}> <Text style={styles.footer}>© 2026 WellNuo Inc.</Text>
<Text style={styles.copyright}>© 2024 WellNuo Inc.</Text>
<Text style={styles.footerText}>All rights reserved.</Text>
<Text style={styles.madeWith}>
Made with for families worldwide
</Text>
</View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
function FeatureItem({ icon, title, subtitle, last }: {
icon: string;
title: string;
subtitle: string;
last?: boolean;
}) {
return (
<View style={[styles.featureItem, !last && styles.itemBorder]}>
<View style={styles.featureIcon}>
<Ionicons name={icon as any} size={20} color="#0A84FF" />
</View>
<View style={styles.featureContent}>
<Text style={styles.featureTitle}>{title}</Text>
<Text style={styles.featureSubtitle}>{subtitle}</Text>
</View>
</View>
);
}
function LinkItem({ title, onPress, last }: {
title: string;
onPress: () => void;
last?: boolean;
}) {
return (
<TouchableOpacity
style={[styles.linkItem, !last && styles.itemBorder]}
onPress={onPress}
activeOpacity={0.6}
>
<Text style={styles.linkTitle}>{title}</Text>
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
</TouchableOpacity>
);
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: AppColors.surface, backgroundColor: '#F2F2F7',
}, },
heroSection: { scrollContent: {
paddingBottom: 40,
},
header: {
alignItems: 'center', alignItems: 'center',
paddingVertical: Spacing.xl, paddingVertical: 32,
backgroundColor: AppColors.background, backgroundColor: '#FFFFFF',
}, },
logoImage: { logo: {
width: 180, width: 80,
height: 100, height: 80,
marginBottom: Spacing.sm,
}, },
appTagline: { appName: {
fontSize: FontSizes.base, fontSize: 28,
color: AppColors.textSecondary, fontWeight: '700',
marginTop: Spacing.xs, color: '#000000',
marginTop: 16,
},
tagline: {
fontSize: 15,
color: '#8E8E93',
marginTop: 4,
},
version: {
fontSize: 13,
color: '#C7C7CC',
marginTop: 8,
}, },
section: { section: {
marginTop: Spacing.md, marginTop: 24,
}, },
sectionTitle: { sectionTitle: {
fontSize: FontSizes.sm, fontSize: 13,
fontWeight: '600', fontWeight: '600',
color: AppColors.textSecondary, color: '#8E8E93',
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.sm,
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 0.5,
marginLeft: 16,
marginBottom: 8,
}, },
card: { card: {
backgroundColor: AppColors.background, backgroundColor: '#FFFFFF',
paddingVertical: Spacing.sm, marginHorizontal: 16,
borderRadius: 12,
}, },
infoRow: { cardText: {
flexDirection: 'row', fontSize: 15,
justifyContent: 'space-between',
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.lg,
},
infoLabel: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
},
infoValue: {
fontSize: FontSizes.base,
fontWeight: '500',
color: AppColors.textPrimary,
},
infoDivider: {
height: 1,
backgroundColor: AppColors.border,
marginHorizontal: Spacing.lg,
},
description: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
lineHeight: 22, lineHeight: 22,
paddingHorizontal: Spacing.lg, color: '#000000',
paddingVertical: Spacing.sm, padding: 16,
}, },
featureItem: { featureItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: Spacing.sm, padding: 14,
paddingHorizontal: Spacing.lg,
}, },
featureIcon: { featureIcon: {
width: 40, width: 36,
height: 40, height: 36,
borderRadius: BorderRadius.md, borderRadius: 8,
justifyContent: 'center', backgroundColor: 'rgba(10, 132, 255, 0.1)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center',
}, },
featureContent: { featureContent: {
flex: 1, flex: 1,
marginLeft: Spacing.md, marginLeft: 12,
}, },
featureTitle: { featureTitle: {
fontSize: FontSizes.base, fontSize: 16,
fontWeight: '500', fontWeight: '500',
color: AppColors.textPrimary, color: '#000000',
}, },
featureDescription: { featureSubtitle: {
fontSize: FontSizes.xs, fontSize: 13,
color: AppColors.textMuted, color: '#8E8E93',
marginTop: 2, marginTop: 2,
}, },
linkRow: { linkItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: Spacing.md, justifyContent: 'space-between',
paddingHorizontal: Spacing.lg, padding: 14,
paddingHorizontal: 16,
}, },
linkText: { linkTitle: {
flex: 1, fontSize: 16,
fontSize: FontSizes.base, color: '#000000',
color: AppColors.primary,
marginLeft: Spacing.md,
}, },
linkDivider: { itemBorder: {
height: 1, borderBottomWidth: StyleSheet.hairlineWidth,
backgroundColor: AppColors.border, borderBottomColor: '#E5E5EA',
marginLeft: Spacing.lg + 20 + Spacing.md,
}, },
footer: { footer: {
alignItems: 'center', textAlign: 'center',
paddingVertical: Spacing.xl, fontSize: 13,
paddingBottom: Spacing.xxl, color: '#8E8E93',
}, marginTop: 32,
copyright: {
fontSize: FontSizes.sm,
fontWeight: '500',
color: AppColors.textPrimary,
},
footerText: {
fontSize: FontSizes.xs,
color: AppColors.textMuted,
marginTop: 2,
},
madeWith: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
marginTop: Spacing.md,
}, },
}); });

View File

@ -1,8 +1,61 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const Stripe = require('stripe');
const { supabase } = require('../config/supabase'); const { supabase } = require('../config/supabase');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
/**
* Helper: Get subscription status from Stripe (source of truth)
*/
async function getStripeSubscriptionStatus(stripeCustomerId) {
if (!stripeCustomerId) {
return { plan: 'free', status: 'none', hasSubscription: false };
}
try {
// Get active subscriptions from Stripe
const subscriptions = await stripe.subscriptions.list({
customer: stripeCustomerId,
status: 'active',
limit: 1
});
if (subscriptions.data.length > 0) {
const sub = subscriptions.data[0];
return {
plan: 'premium',
status: 'active',
hasSubscription: true,
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: sub.cancel_at_period_end
};
}
// Check for past_due or other statuses
const allSubs = await stripe.subscriptions.list({
customer: stripeCustomerId,
limit: 1
});
if (allSubs.data.length > 0) {
const sub = allSubs.data[0];
return {
plan: sub.status === 'canceled' ? 'free' : 'premium',
status: sub.status,
hasSubscription: false,
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString()
};
}
return { plan: 'free', status: 'none', hasSubscription: false };
} catch (error) {
console.error('Error fetching Stripe subscription:', error);
return { plan: 'free', status: 'none', hasSubscription: false };
}
}
/** /**
* Middleware to verify JWT token * Middleware to verify JWT token
*/ */
@ -118,13 +171,8 @@ router.get('/:id', async (req, res) => {
return res.status(404).json({ error: 'Beneficiary not found' }); return res.status(404).json({ error: 'Beneficiary not found' });
} }
// Get subscription for this beneficiary // Get subscription status from Stripe (source of truth)
const { data: subscription } = await supabase const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id);
.from('subscriptions')
.select('*')
.eq('beneficiary_id', beneficiaryId)
.eq('user_id', userId)
.single();
// Get orders for this beneficiary // Get orders for this beneficiary
const { data: orders } = await supabase const { data: orders } = await supabase
@ -148,11 +196,7 @@ router.get('/:id', async (req, res) => {
country: beneficiary.address_country country: beneficiary.address_country
}, },
role: access.role, role: access.role,
subscription: subscription ? { subscription: subscription,
plan: subscription.plan,
status: subscription.status,
currentPeriodEnd: subscription.current_period_end
} : { plan: 'free', status: 'active' },
orders: orders || [] orders: orders || []
}); });

View File

@ -204,6 +204,369 @@ router.get('/products', async (req, res) => {
} }
}); });
/**
* Helper: Get or create Stripe Customer for a beneficiary
* Customer is tied to BENEFICIARY (not user) so subscription persists when access is transferred
*/
async function getOrCreateStripeCustomer(beneficiaryId) {
// Get beneficiary from DB
const { data: beneficiary, error } = await supabase
.from('users')
.select('id, email, first_name, last_name, stripe_customer_id')
.eq('id', beneficiaryId)
.single();
if (error || !beneficiary) {
throw new Error('Beneficiary not found');
}
// If already has Stripe customer, return it
if (beneficiary.stripe_customer_id) {
return beneficiary.stripe_customer_id;
}
// Create new Stripe customer for this beneficiary
const customer = await stripe.customers.create({
email: beneficiary.email,
name: `${beneficiary.first_name || ''} ${beneficiary.last_name || ''}`.trim() || undefined,
metadata: {
beneficiary_id: beneficiary.id.toString(),
type: 'beneficiary'
}
});
// Save stripe_customer_id to DB
await supabase
.from('users')
.update({ stripe_customer_id: customer.id })
.eq('id', beneficiaryId);
console.log(`✓ Created Stripe customer ${customer.id} for beneficiary ${beneficiaryId}`);
return customer.id;
}
/**
* POST /api/stripe/create-subscription
* Creates a Stripe Subscription for a beneficiary
* Uses Stripe as the source of truth - no local subscription table needed!
*/
router.post('/create-subscription', async (req, res) => {
try {
const { beneficiaryId, paymentMethodId } = req.body;
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
}
// Get or create Stripe customer for this beneficiary
const customerId = await getOrCreateStripeCustomer(beneficiaryId);
// Attach payment method to customer if provided
if (paymentMethodId) {
await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
await stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId }
});
}
// Check if customer already has an active subscription
const existingSubs = await stripe.subscriptions.list({
customer: customerId,
status: 'active',
limit: 1
});
if (existingSubs.data.length > 0) {
const sub = existingSubs.data[0];
return res.json({
success: true,
subscription: {
id: sub.id,
status: sub.status,
plan: 'premium',
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: sub.cancel_at_period_end
},
message: 'Subscription already active'
});
}
// Create new subscription in Stripe
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: process.env.STRIPE_PRICE_PREMIUM }],
payment_behavior: 'default_incomplete',
payment_settings: { save_default_payment_method: 'on_subscription' },
expand: ['latest_invoice.payment_intent'],
metadata: {
beneficiary_id: beneficiaryId.toString()
}
});
console.log(`✓ Created Stripe subscription ${subscription.id} for beneficiary ${beneficiaryId}`);
res.json({
success: true,
subscription: {
id: subscription.id,
status: subscription.status,
plan: 'premium',
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString()
},
clientSecret: subscription.latest_invoice?.payment_intent?.client_secret
});
} catch (error) {
console.error('Create subscription error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/stripe/subscription-status/:beneficiaryId
* Gets subscription status directly from Stripe (source of truth)
*/
router.get('/subscription-status/:beneficiaryId', async (req, res) => {
try {
const { beneficiaryId } = req.params;
// Get beneficiary's stripe_customer_id
const { data: beneficiary } = await supabase
.from('users')
.select('stripe_customer_id')
.eq('id', beneficiaryId)
.single();
if (!beneficiary?.stripe_customer_id) {
return res.json({
status: 'none',
plan: 'free',
hasSubscription: false
});
}
// Get active subscriptions from Stripe
const subscriptions = await stripe.subscriptions.list({
customer: beneficiary.stripe_customer_id,
status: 'active',
limit: 1
});
if (subscriptions.data.length === 0) {
// Check for past_due or canceled
const allSubs = await stripe.subscriptions.list({
customer: beneficiary.stripe_customer_id,
limit: 1
});
if (allSubs.data.length > 0) {
const sub = allSubs.data[0];
return res.json({
status: sub.status,
plan: sub.status === 'canceled' ? 'free' : 'premium',
hasSubscription: false,
subscriptionId: sub.id,
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString()
});
}
return res.json({
status: 'none',
plan: 'free',
hasSubscription: false
});
}
const subscription = subscriptions.data[0];
res.json({
status: 'active',
plan: 'premium',
hasSubscription: true,
subscriptionId: subscription.id,
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
cancelAtPeriodEnd: subscription.cancel_at_period_end
});
} catch (error) {
console.error('Get subscription status error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/stripe/cancel-subscription
* Cancels subscription at period end
*/
router.post('/cancel-subscription', async (req, res) => {
try {
const { beneficiaryId } = req.body;
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
}
// Get beneficiary's stripe_customer_id
const { data: beneficiary } = await supabase
.from('users')
.select('stripe_customer_id')
.eq('id', beneficiaryId)
.single();
if (!beneficiary?.stripe_customer_id) {
return res.status(404).json({ error: 'No subscription found' });
}
// Get active subscription
const subscriptions = await stripe.subscriptions.list({
customer: beneficiary.stripe_customer_id,
status: 'active',
limit: 1
});
if (subscriptions.data.length === 0) {
return res.status(404).json({ error: 'No active subscription found' });
}
// Cancel at period end (not immediately)
const subscription = await stripe.subscriptions.update(subscriptions.data[0].id, {
cancel_at_period_end: true
});
console.log(`✓ Subscription ${subscription.id} will cancel at period end`);
res.json({
success: true,
message: 'Subscription will cancel at the end of the billing period',
cancelAt: new Date(subscription.current_period_end * 1000).toISOString()
});
} catch (error) {
console.error('Cancel subscription error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/stripe/reactivate-subscription
* Reactivates a subscription that was set to cancel
*/
router.post('/reactivate-subscription', async (req, res) => {
try {
const { beneficiaryId } = req.body;
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
}
const { data: beneficiary } = await supabase
.from('users')
.select('stripe_customer_id')
.eq('id', beneficiaryId)
.single();
if (!beneficiary?.stripe_customer_id) {
return res.status(404).json({ error: 'No subscription found' });
}
const subscriptions = await stripe.subscriptions.list({
customer: beneficiary.stripe_customer_id,
limit: 1
});
if (subscriptions.data.length === 0) {
return res.status(404).json({ error: 'No subscription found' });
}
const subscription = await stripe.subscriptions.update(subscriptions.data[0].id, {
cancel_at_period_end: false
});
console.log(`✓ Subscription ${subscription.id} reactivated`);
res.json({
success: true,
message: 'Subscription reactivated',
status: subscription.status
});
} catch (error) {
console.error('Reactivate subscription error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/stripe/create-subscription-payment-sheet
* Creates a SetupIntent for collecting payment method in React Native app
* Then creates subscription with that payment method
*/
router.post('/create-subscription-payment-sheet', async (req, res) => {
try {
const { beneficiaryId } = req.body;
if (!beneficiaryId) {
return res.status(400).json({ error: 'beneficiaryId is required' });
}
// Get or create Stripe customer for this beneficiary
const customerId = await getOrCreateStripeCustomer(beneficiaryId);
// Check if already has active subscription
const existingSubs = await stripe.subscriptions.list({
customer: customerId,
status: 'active',
limit: 1
});
if (existingSubs.data.length > 0) {
return res.json({
alreadySubscribed: true,
subscription: {
id: existingSubs.data[0].id,
status: 'active',
currentPeriodEnd: new Date(existingSubs.data[0].current_period_end * 1000).toISOString()
}
});
}
// Create ephemeral key
const ephemeralKey = await stripe.ephemeralKeys.create(
{ customer: customerId },
{ apiVersion: '2024-12-18.acacia' }
);
// Create subscription with incomplete status (needs payment)
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: process.env.STRIPE_PRICE_PREMIUM }],
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
payment_method_types: ['card']
},
expand: ['latest_invoice.payment_intent'],
metadata: {
beneficiary_id: beneficiaryId.toString()
}
});
const paymentIntent = subscription.latest_invoice?.payment_intent;
res.json({
subscriptionId: subscription.id,
clientSecret: paymentIntent?.client_secret,
ephemeralKey: ephemeralKey.secret,
customer: customerId,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY
});
} catch (error) {
console.error('Create subscription payment sheet error:', error);
res.status(500).json({ error: error.message });
}
});
/** /**
* GET /api/stripe/session/:sessionId * GET /api/stripe/session/:sessionId
* Get checkout session details (for success page) * Get checkout session details (for success page)

View File

@ -8,7 +8,6 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { usePaymentSheet } from '@stripe/stripe-react-native'; import { usePaymentSheet } from '@stripe/stripe-react-native';
import { useAuth } from '@/contexts/AuthContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme'; import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
@ -26,7 +25,6 @@ interface SubscriptionPaymentProps {
export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }: SubscriptionPaymentProps) { export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }: SubscriptionPaymentProps) {
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet(); const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
const { user } = useAuth();
const { updateLocalBeneficiary } = useBeneficiary(); const { updateLocalBeneficiary } = useBeneficiary();
const toast = useToast(); const toast = useToast();
@ -36,40 +34,50 @@ export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }:
setIsProcessing(true); setIsProcessing(true);
try { try {
// 1. Create Payment Sheet on our server // 1. Create subscription payment sheet via new Stripe Subscriptions API
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, { const response = await fetch(`${STRIPE_API_URL}/create-subscription-payment-sheet`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
email: user?.email || 'guest@wellnuo.com',
amount: SUBSCRIPTION_PRICE * 100, // Convert to cents ($49.00)
metadata: {
type: 'subscription',
planType: 'monthly',
userId: user?.user_id || 'guest',
beneficiaryId: beneficiary.id, beneficiaryId: beneficiary.id,
beneficiaryName: beneficiary.name,
},
}), }),
}); });
const data = await response.json(); const data = await response.json();
if (!data.paymentIntent) { // Check if already subscribed
throw new Error(data.error || 'Failed to create payment sheet'); if (data.alreadySubscribed) {
toast.success(
'Already Subscribed!',
`${beneficiary.name} already has an active subscription.`
);
// Update local state
await updateLocalBeneficiary(beneficiary.id, {
subscription: {
status: 'active',
endDate: data.subscription.currentPeriodEnd,
planType: 'monthly',
price: SUBSCRIPTION_PRICE,
},
});
onSuccess?.();
return;
}
if (!data.clientSecret) {
throw new Error(data.error || 'Failed to create subscription');
} }
// 2. Initialize the Payment Sheet // 2. Initialize the Payment Sheet
const { error: initError } = await initPaymentSheet({ const { error: initError } = await initPaymentSheet({
merchantDisplayName: 'WellNuo', merchantDisplayName: 'WellNuo',
paymentIntentClientSecret: data.paymentIntent, paymentIntentClientSecret: data.clientSecret,
customerId: data.customer, customerId: data.customer,
customerEphemeralKeySecret: data.ephemeralKey, customerEphemeralKeySecret: data.ephemeralKey,
defaultBillingDetails: {
email: user?.email || '',
},
returnURL: 'wellnuo://stripe-redirect', returnURL: 'wellnuo://stripe-redirect',
applePay: { applePay: {
merchantCountryCode: 'US', merchantCountryCode: 'US',
@ -95,15 +103,17 @@ export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }:
throw new Error(presentError.message); throw new Error(presentError.message);
} }
// 4. Payment successful! Save subscription to beneficiary // 4. Payment successful! Subscription is now active in Stripe
const now = new Date(); // Fetch the current status from Stripe to confirm
const endDate = new Date(now); const statusResponse = await fetch(
endDate.setMonth(endDate.getMonth() + 1); // 1 month subscription `${STRIPE_API_URL}/subscription-status/${beneficiary.id}`
);
const statusData = await statusResponse.json();
// Update local state with data from Stripe
const newSubscription: BeneficiarySubscription = { const newSubscription: BeneficiarySubscription = {
status: 'active', status: statusData.status === 'active' ? 'active' : 'none',
startDate: now.toISOString(), endDate: statusData.currentPeriodEnd,
endDate: endDate.toISOString(),
planType: 'monthly', planType: 'monthly',
price: SUBSCRIPTION_PRICE, price: SUBSCRIPTION_PRICE,
}; };

179
hooks/useNavigationFlow.ts Normal file
View File

@ -0,0 +1,179 @@
/**
* useNavigationFlow - React hook for centralized navigation
*
* This hook wraps NavigationController and provides easy-to-use
* navigation methods for components.
*
* USAGE:
* const nav = useNavigationFlow();
* nav.navigateAfterLogin(profile, beneficiaries);
* nav.navigateAfterAddBeneficiary(42, false);
*/
import { useCallback } from 'react';
import { useRouter } from 'expo-router';
import { NavigationController, ROUTES, type NavigationResult, type UserProfile } from '@/services/NavigationController';
import type { Beneficiary } from '@/types';
export function useNavigationFlow() {
const router = useRouter();
/**
* Execute navigation based on NavigationResult
*/
const navigate = useCallback((result: NavigationResult, replace = false) => {
const href = result.params
? { pathname: result.path, params: result.params }
: result.path;
if (replace) {
router.replace(href as any);
} else {
router.push(href as any);
}
}, [router]);
/**
* Navigate after successful login/OTP verification
*/
const navigateAfterLogin = useCallback((
profile: UserProfile | null,
beneficiaries: Beneficiary[]
) => {
const result = NavigationController.getRouteAfterLogin(profile, beneficiaries);
navigate(result, true); // replace to prevent going back to login
}, [navigate]);
/**
* Navigate after creating a new beneficiary
*/
const navigateAfterAddBeneficiary = useCallback((
beneficiaryId: number,
hasExistingDevices: boolean
) => {
const result = NavigationController.getRouteAfterAddBeneficiary(
beneficiaryId,
hasExistingDevices
);
navigate(result);
}, [navigate]);
/**
* Navigate after purchase completion
*/
const navigateAfterPurchase = useCallback((
beneficiaryId: number,
options: { skipToActivate?: boolean; demo?: boolean } = {}
) => {
const result = NavigationController.getRouteAfterPurchase(beneficiaryId, options);
navigate(result);
}, [navigate]);
/**
* Navigate after device activation
*/
const navigateAfterActivation = useCallback((beneficiaryId: number) => {
const result = NavigationController.getRouteAfterActivation(beneficiaryId);
navigate(result, true); // replace to prevent going back
}, [navigate]);
/**
* Navigate to beneficiary-specific screens
*/
const navigateToBeneficiary = useCallback((
beneficiary: Beneficiary,
action: 'view' | 'subscription' | 'equipment' | 'share' = 'view'
) => {
const result = NavigationController.getBeneficiaryRoute(beneficiary, action);
navigate(result);
}, [navigate]);
/**
* Navigate to beneficiary setup flow (purchase or activate)
*/
const navigateToBeneficiarySetup = useCallback((beneficiary: Beneficiary) => {
const result = NavigationController.getRouteForBeneficiarySetup(beneficiary);
navigate(result);
}, [navigate]);
/**
* Navigate to specific route
*/
const goTo = useCallback((
path: string,
params?: Record<string, string | number | boolean>,
replace = false
) => {
navigate({ path, params }, replace);
}, [navigate]);
/**
* Quick navigation shortcuts
*/
const goToDashboard = useCallback(() => {
navigate({ path: ROUTES.TABS.DASHBOARD }, true);
}, [navigate]);
const goToLogin = useCallback(() => {
navigate({ path: ROUTES.AUTH.LOGIN }, true);
}, [navigate]);
const goToAddBeneficiary = useCallback(() => {
navigate({ path: ROUTES.AUTH.ADD_LOVED_ONE });
}, [navigate]);
const goToPurchase = useCallback((beneficiaryId: number) => {
navigate({
path: ROUTES.AUTH.PURCHASE,
params: { beneficiaryId },
});
}, [navigate]);
const goToActivate = useCallback((beneficiaryId: number, demo?: boolean) => {
navigate({
path: ROUTES.AUTH.ACTIVATE,
params: { beneficiaryId, demo },
});
}, [navigate]);
const goToProfile = useCallback(() => {
navigate({ path: ROUTES.TABS.PROFILE });
}, [navigate]);
const goToEditProfile = useCallback(() => {
navigate({ path: ROUTES.PROFILE.EDIT });
}, [navigate]);
return {
// Core navigation methods
navigate,
goTo,
// Flow-based navigation
navigateAfterLogin,
navigateAfterAddBeneficiary,
navigateAfterPurchase,
navigateAfterActivation,
// Beneficiary navigation
navigateToBeneficiary,
navigateToBeneficiarySetup,
// Quick shortcuts
goToDashboard,
goToLogin,
goToAddBeneficiary,
goToPurchase,
goToActivate,
goToProfile,
goToEditProfile,
// Direct access to routes
ROUTES,
// Direct access to controller for advanced use
controller: NavigationController,
};
}
export default useNavigationFlow;

View File

@ -0,0 +1,375 @@
/**
* NavigationController - Centralized navigation logic for WellNuo app
*
* This service provides all routing decisions based on user state,
* beneficiary data, and equipment status.
*
* USAGE:
* import { NavigationController } from '@/services/NavigationController';
* const { path, params } = NavigationController.getRouteAfterLogin(profile, beneficiaries);
* router.replace(path);
*/
import type { Beneficiary, EquipmentStatus } from '@/types';
// ==================== ROUTE CONSTANTS ====================
export const ROUTES = {
// Auth Flow
AUTH: {
LOGIN: '/(auth)/login',
VERIFY_OTP: '/(auth)/verify-otp',
ENTER_NAME: '/(auth)/enter-name',
ADD_LOVED_ONE: '/(auth)/add-loved-one',
PURCHASE: '/(auth)/purchase',
ACTIVATE: '/(auth)/activate',
WELCOME_BACK: '/(auth)/welcome-back',
COMPLETE_PROFILE: '/(auth)/complete-profile',
},
// Main Tabs
TABS: {
DASHBOARD: '/(tabs)/dashboard',
BENEFICIARIES: '/(tabs)/beneficiaries',
CHAT: '/(tabs)/chat',
VOICE: '/(tabs)/voice',
PROFILE: '/(tabs)/profile',
},
// Beneficiary Management
BENEFICIARY: {
ADD: '/(tabs)/beneficiaries/add',
DETAIL: (id: number) => `/(tabs)/beneficiaries/${id}` as const,
SUBSCRIPTION: (id: number) => `/(tabs)/beneficiaries/${id}/subscription` as const,
EQUIPMENT: (id: number) => `/(tabs)/beneficiaries/${id}/equipment` as const,
SHARE: (id: number) => `/(tabs)/beneficiaries/${id}/share` as const,
},
// Profile
PROFILE: {
INDEX: '/(tabs)/profile',
EDIT: '/(tabs)/profile/edit',
NOTIFICATIONS: '/(tabs)/profile/notifications',
LANGUAGE: '/(tabs)/profile/language',
PRIVACY: '/(tabs)/profile/privacy',
TERMS: '/(tabs)/profile/terms',
SUPPORT: '/(tabs)/profile/support',
ABOUT: '/(tabs)/profile/about',
HELP: '/(tabs)/profile/help',
},
} as const;
// ==================== TYPE DEFINITIONS ====================
export interface NavigationResult {
path: string;
params?: Record<string, string | number | boolean>;
}
export interface UserProfile {
id: number;
email: string;
firstName: string | null;
lastName: string | null;
phone: string | null;
}
// ==================== NAVIGATION CONTROLLER ====================
export const NavigationController = {
/**
* Determine where to navigate after successful OTP verification
*
* Flow:
* 1. New user without name enter-name
* 2. User without beneficiaries add-loved-one
* 3. User has beneficiary without devices check hasDevices
* - hasDevices=false purchase (need to buy equipment)
* - hasDevices=true but equipment not activated activate
* 4. User has active beneficiary dashboard
*/
getRouteAfterLogin(
profile: UserProfile | null,
beneficiaries: Beneficiary[]
): NavigationResult {
// Step 1: Check if user has name
if (!profile?.firstName) {
return {
path: ROUTES.AUTH.ENTER_NAME,
};
}
// Step 2: Check if user has any beneficiaries
if (!beneficiaries || beneficiaries.length === 0) {
return {
path: ROUTES.AUTH.ADD_LOVED_ONE,
};
}
// Step 3: Check if any beneficiary needs equipment setup
const beneficiaryNeedingSetup = beneficiaries.find(b =>
!b.hasDevices && b.equipmentStatus !== 'active' && b.equipmentStatus !== 'demo'
);
if (beneficiaryNeedingSetup) {
return this.getRouteForBeneficiarySetup(beneficiaryNeedingSetup);
}
// Step 4: All set, go to dashboard
return {
path: ROUTES.TABS.DASHBOARD,
};
},
/**
* Determine where to navigate for beneficiary equipment setup
*/
getRouteForBeneficiarySetup(beneficiary: Beneficiary): NavigationResult {
const status = beneficiary.equipmentStatus || 'none';
switch (status) {
case 'none':
// No equipment ordered - go to purchase
return {
path: ROUTES.AUTH.PURCHASE,
params: { beneficiaryId: beneficiary.id },
};
case 'ordered':
case 'shipped':
// Equipment is on the way - can show tracking or wait screen
// For now, go to equipment status page
return {
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
params: { beneficiaryId: beneficiary.id },
};
case 'delivered':
// Equipment delivered, needs activation
return {
path: ROUTES.AUTH.ACTIVATE,
params: { beneficiaryId: beneficiary.id },
};
case 'active':
case 'demo':
// Already active, go to dashboard
return {
path: ROUTES.TABS.DASHBOARD,
};
default:
// Unknown status - go to dashboard
return {
path: ROUTES.TABS.DASHBOARD,
};
}
},
/**
* After creating a new beneficiary, determine next step
*
* @param beneficiaryId - ID of newly created beneficiary
* @param hasExistingDevices - User indicated they already have WellNuo devices
*/
getRouteAfterAddBeneficiary(
beneficiaryId: number,
hasExistingDevices: boolean
): NavigationResult {
if (hasExistingDevices) {
// User has existing devices - go directly to activation
return {
path: ROUTES.AUTH.ACTIVATE,
params: { beneficiaryId },
};
}
// User needs to purchase equipment
return {
path: ROUTES.AUTH.PURCHASE,
params: { beneficiaryId },
};
},
/**
* After successful purchase, navigate to next step
*/
getRouteAfterPurchase(
beneficiaryId: number,
purchaseResult: { skipToActivate?: boolean; demo?: boolean }
): NavigationResult {
if (purchaseResult.demo || purchaseResult.skipToActivate) {
// Demo mode or skip - go to activate
return {
path: ROUTES.AUTH.ACTIVATE,
params: { beneficiaryId, demo: purchaseResult.demo },
};
}
// Normal purchase - wait for equipment delivery
// Go to equipment tracking page
return {
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiaryId),
params: { beneficiaryId },
};
},
/**
* After successful activation, navigate to beneficiary detail page
*/
getRouteAfterActivation(beneficiaryId: number): NavigationResult {
return {
path: ROUTES.BENEFICIARY.DETAIL(beneficiaryId),
params: { justActivated: true },
};
},
/**
* Get route for returning user (already has account)
*
* Used when user logs in with existing account
*/
getRouteForReturningUser(
profile: UserProfile,
beneficiaries: Beneficiary[]
): NavigationResult {
// Same logic as after login, but could add welcome-back screen
return this.getRouteAfterLogin(profile, beneficiaries);
},
/**
* Check if user should see onboarding
*/
shouldShowOnboarding(
isNewUser: boolean,
profile: UserProfile | null
): boolean {
return isNewUser || !profile?.firstName;
},
/**
* Get route for a specific beneficiary action
*/
getBeneficiaryRoute(
beneficiary: Beneficiary,
action: 'view' | 'subscription' | 'equipment' | 'share'
): NavigationResult {
switch (action) {
case 'view':
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
case 'subscription':
return { path: ROUTES.BENEFICIARY.SUBSCRIPTION(beneficiary.id) };
case 'equipment':
return { path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id) };
case 'share':
return { path: ROUTES.BENEFICIARY.SHARE(beneficiary.id) };
default:
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
}
},
/**
* Determine if beneficiary card should show "Setup Equipment" button
*/
shouldShowEquipmentSetup(beneficiary: Beneficiary): boolean {
return !beneficiary.hasDevices &&
beneficiary.equipmentStatus !== 'active' &&
beneficiary.equipmentStatus !== 'demo';
},
/**
* Get call-to-action for beneficiary based on status
*/
getBeneficiaryCallToAction(
beneficiary: Beneficiary
): { label: string; action: () => NavigationResult } | null {
const status = beneficiary.equipmentStatus || 'none';
if (beneficiary.hasDevices || status === 'active' || status === 'demo') {
// No CTA needed - beneficiary is active
return null;
}
switch (status) {
case 'none':
return {
label: 'Setup Equipment',
action: () => ({
path: ROUTES.AUTH.PURCHASE,
params: { beneficiaryId: beneficiary.id },
}),
};
case 'ordered':
return {
label: 'Track Delivery',
action: () => ({
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
}),
};
case 'shipped':
return {
label: 'Track Package',
action: () => ({
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
}),
};
case 'delivered':
return {
label: 'Activate Equipment',
action: () => ({
path: ROUTES.AUTH.ACTIVATE,
params: { beneficiaryId: beneficiary.id },
}),
};
default:
return null;
}
},
/**
* Get status text for equipment
*/
getEquipmentStatusText(status: EquipmentStatus | undefined): string {
switch (status) {
case 'none':
return 'No equipment';
case 'ordered':
return 'Equipment ordered';
case 'shipped':
return 'In transit';
case 'delivered':
return 'Ready to activate';
case 'active':
return 'Active';
case 'demo':
return 'Demo mode';
default:
return 'Unknown';
}
},
/**
* Get status color for equipment
*/
getEquipmentStatusColor(status: EquipmentStatus | undefined): string {
switch (status) {
case 'none':
return '#888888'; // gray
case 'ordered':
return '#FFA500'; // orange
case 'shipped':
return '#007AFF'; // blue
case 'delivered':
return '#34C759'; // green
case 'active':
return '#34C759'; // green
case 'demo':
return '#9C27B0'; // purple
default:
return '#888888'; // gray
}
},
};
export default NavigationController;