diff --git a/CLAUDE.md b/CLAUDE.md
index a3337a8..d10779f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -12,7 +12,7 @@
- Every CRUD operation goes through the WellNuo API
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
- 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 |
| PATCH | `/me/beneficiaries/:id` | Update beneficiary |
| DELETE | `/me/beneficiaries/:id` | Remove beneficiary access |
-| GET | `/me/profile` | Get current user profile |
-| PATCH | `/me/profile` | Update user profile (firstName, lastName, phone) |
+| POST | `/me/beneficiaries/:id/activate` | Activate device for beneficiary |
+| 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)
Base URL: `https://eluxnetworks.net/function/well-api/api`
@@ -43,10 +44,10 @@ Used only for:
### Data Flow
```
-┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
-│ React App │ ──▶ │ WellNuo Backend │ ──▶ │ Supabase │
-│ (Expo) │ ◀── │ (Express.js) │ ◀── │ (Postgres) │
-└─────────────┘ └──────────────────┘ └─────────────┘
+┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
+│ React App │ ──▶ │ WellNuo Backend │ ──▶ │ PostgreSQL │
+│ (Expo) │ ◀── │ (Express.js) │ ◀── │ (eluxnetworks.net)│
+└─────────────┘ └──────────────────┘ └──────────────────┘
```
### Database Schema
@@ -69,12 +70,127 @@ Used only for:
### What TO do
- ✅ 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
- ✅ Handle loading and error states for all API calls
- ✅ Show appropriate feedback on successful operations
- ✅ 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
### Server Location
@@ -83,6 +199,8 @@ Used only for:
### Key Files
- `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)
- `app/(tabs)/beneficiaries/` - Beneficiary screens
diff --git a/app/(auth)/activate.tsx b/app/(auth)/activate.tsx
index b4fecdd..23d8dbc 100644
--- a/app/(auth)/activate.tsx
+++ b/app/(auth)/activate.tsx
@@ -13,27 +13,19 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
-import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { api } from '@/services/api';
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 beneficiaryId = params.beneficiaryId ? parseInt(params.beneficiaryId, 10) : null;
+ const lovedOneName = params.lovedOneName || '';
const [activationCode, setActivationCode] = useState('');
const [isActivating, setIsActivating] = useState(false);
- const [step, setStep] = useState<'code' | 'beneficiary' | '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;
+ const [step, setStep] = useState<'code' | 'complete'>('code');
// Demo serial for testing without real hardware
const DEMO_SERIAL = 'DEMO-00000';
@@ -55,49 +47,26 @@ export default function ActivateScreen() {
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);
try {
- // If we have an existing beneficiary, activate via API (persists to server!)
- if (existingBeneficiaryId && existingBeneficiary) {
- // Call API to persist activation - this creates order with 'installed' status
- const response = await api.activateBeneficiary(existingBeneficiaryId, code);
+ // Call API to activate - sets has_existing_devices = true on backend
+ const response = await api.activateBeneficiary(beneficiaryId, code);
- if (!response.ok) {
- Alert.alert('Error', response.error?.message || 'Failed to activate equipment');
- return;
- }
-
- setBeneficiaryName(existingBeneficiary.name);
- 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);
- setStep('complete');
- } else {
- // No name provided, show beneficiary form
- setStep('beneficiary');
- }
+ if (!response.ok) {
+ Alert.alert('Error', response.error?.message || 'Failed to activate equipment');
+ return;
}
+
+ // Mark onboarding as completed
+ await api.setOnboardingCompleted(true);
+ setStep('complete');
} catch (error) {
console.error('Failed to activate:', error);
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 = () => {
- // If updating existing beneficiary, go back to their detail page
- if (existingBeneficiaryId) {
- router.replace(`/(tabs)/beneficiaries/${existingBeneficiaryId}`);
+ // Navigate to beneficiary detail page after activation
+ if (beneficiaryId) {
+ router.replace(`/(tabs)/beneficiaries/${beneficiaryId}` as const);
} else {
- // Navigate to main app
router.replace('/(tabs)');
}
};
@@ -163,16 +91,10 @@ export default function ActivateScreen() {
{/* Header */}
- {existingBeneficiary ? (
- router.back()}>
-
-
- ) : (
-
- )}
-
- {existingBeneficiary ? 'Connect Sensors' : 'Activate Kit'}
-
+ router.back()}>
+
+
+ Connect Sensors
@@ -183,9 +105,9 @@ export default function ActivateScreen() {
{/* Instructions */}
- {existingBeneficiary
- ? `Connect sensors for ${existingBeneficiary.name}`
- : 'Enter the activation code from your WellNuo Starter Kit packaging'}
+ {lovedOneName
+ ? `Connect sensors for ${lovedOneName}`
+ : 'Enter the serial number from your sensors'}
{/* Input */}
@@ -222,76 +144,12 @@ export default function ActivateScreen() {
Activate
)}
-
- {/* Skip for now - only show for new onboarding, not for existing beneficiary */}
- {!existingBeneficiary && (
-
- Skip for now
-
- )}
);
}
- // Step 2: Add beneficiary
- if (step === 'beneficiary') {
- return (
-
-
- {/* Header */}
-
- setStep('code')}>
-
-
- Add Beneficiary
-
-
-
- {/* Icon */}
-
-
-
-
- {/* Instructions */}
-
- Who will you be monitoring with this kit?
-
-
- {/* Name Input */}
-
- Full Name
-
-
-
- {/* Continue Button */}
-
- {isActivating ? (
-
- ) : (
- Continue
- )}
-
-
-
- );
- }
-
- // Step 3: Complete
- const displayName = beneficiaryName || existingBeneficiary?.name || params.lovedOneName;
-
+ // Step 2: Complete
return (
@@ -301,54 +159,33 @@ export default function ActivateScreen() {
-
- {existingBeneficiary ? 'Sensors Connected!' : 'Kit Activated!'}
-
+ Sensors Connected!
- {existingBeneficiary
- ? `Sensors have been connected for `
- : `Your WellNuo kit has been successfully activated for `}
- {displayName}
+ Your sensors have been successfully connected for{' '}
+ {lovedOneName || 'your loved one'}
{/* Next Steps */}
Next Steps:
- {existingBeneficiary ? (
- <>
-
- 1
- Connect the hub to WiFi
-
-
- 2
- Subscribe to start monitoring ($49/month)
-
- >
- ) : (
- <>
-
- 1
- Place sensors in your loved one's home
-
-
- 2
- Connect the hub to WiFi
-
-
- 3
- Subscribe to start monitoring ($49/month)
-
- >
- )}
+
+ 1
+ Place sensors in your loved one's home
+
+
+ 2
+ Connect the hub to WiFi
+
+
+ 3
+ Subscribe to start monitoring ($49/month)
+
{/* Complete Button */}
-
- {existingBeneficiary ? 'Continue' : 'Go to Dashboard'}
-
+ Go to Dashboard
@@ -396,15 +233,6 @@ const styles = StyleSheet.create({
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,
@@ -416,41 +244,6 @@ const styles = StyleSheet.create({
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,
- },
demoCodeLink: {
flexDirection: 'row',
alignItems: 'center',
@@ -477,15 +270,6 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
- skipButton: {
- alignItems: 'center',
- paddingVertical: Spacing.lg,
- },
- skipButtonText: {
- fontSize: FontSizes.base,
- color: AppColors.textSecondary,
- textDecorationLine: 'underline',
- },
successContainer: {
flex: 1,
alignItems: 'center',
diff --git a/app/(tabs)/beneficiaries/[id]/index.tsx b/app/(tabs)/beneficiaries/[id]/index.tsx
index 0ba79f8..2d64a14 100644
--- a/app/(tabs)/beneficiaries/[id]/index.tsx
+++ b/app/(tabs)/beneficiaries/[id]/index.tsx
@@ -48,11 +48,18 @@ type SetupState = 'loading' | 'awaiting_equipment' | 'no_devices' | 'no_subscrip
// Stripe API
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
-// WebView Dashboard URL - uses test NDK account for demo data
-const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
+// WebView Dashboard URL - opens specific deployment directly
+// 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
const TEST_NDK_USER = 'anandk';
const TEST_NDK_PASSWORD = 'anandk_8';
+// Ferdinand's default deployment ID (has sensor data)
+const FERDINAND_DEPLOYMENT_ID = 21;
// Starter Kit info
const STARTER_KIT = {
@@ -697,7 +704,7 @@ export default function BeneficiaryDetailScreen() {
isWebViewReady && authToken ? (
{
loadBeneficiary();
@@ -58,8 +59,12 @@ export default function SubscriptionScreen() {
if (!id) return;
try {
- const data = await getBeneficiaryById(id);
- setBeneficiary(data);
+ const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
+ if (response.ok && response.data) {
+ setBeneficiary(response.data);
+ } else {
+ console.error('Failed to load beneficiary:', response.error);
+ }
} catch (error) {
console.error('Failed to load beneficiary:', error);
} finally {
diff --git a/app/(tabs)/profile/about.tsx b/app/(tabs)/profile/about.tsx
index 321a2fa..23198b0 100644
--- a/app/(tabs)/profile/about.tsx
+++ b/app/(tabs)/profile/about.tsx
@@ -10,39 +10,8 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { PageHeader } from '@/components/PageHeader';
-interface InfoRowProps {
- label: string;
- value: string;
-}
-
-function InfoRow({ label, value }: InfoRowProps) {
- return (
-
- {label}
- {value}
-
- );
-}
-
-interface LinkRowProps {
- icon: keyof typeof Ionicons.glyphMap;
- title: string;
- onPress: () => void;
-}
-
-function LinkRow({ icon, title, onPress }: LinkRowProps) {
- return (
-
-
- {title}
-
-
- );
-}
-
export default function AboutScreen() {
const openURL = (url: string) => {
Linking.openURL(url).catch(() => {});
@@ -50,255 +19,228 @@ export default function AboutScreen() {
return (
-
-
- {/* App Logo & Name */}
-
+
+
+ {/* Header */}
+
- Caring for Those Who Matter Most
-
-
- {/* Version Info */}
-
- App Information
-
-
-
-
-
-
-
-
-
-
-
+ WellNuo
+ Caring for those who matter most
+ Version 1.0.0
{/* Description */}
-
- About
-
-
- WellNuo is a comprehensive elderly care monitoring application designed to help
- families and caregivers stay connected with their loved ones. Using advanced
- sensor technology and AI-powered analytics, WellNuo provides real-time insights
- into daily activities, health patterns, and emergency situations.
-
-
- Our mission is to bring peace of mind to families while preserving the independence
- and dignity of elderly individuals.
-
-
+
+
+ WellNuo helps families stay connected with their loved ones through
+ smart monitoring and AI-powered insights.
+
{/* Features */}
- Key Features
+ Features
-
-
-
-
-
- Real-time Monitoring
- 24/7 activity and wellness tracking
-
-
-
-
-
-
-
- Emergency Alerts
- Instant notifications for falls and emergencies
-
-
-
-
-
-
-
- AI-Powered Insights
- Smart analysis of health patterns
-
-
-
-
-
-
-
- Family Coordination
- Share care with multiple caregivers
-
-
+
+
+
+
{/* Links */}
- Resources
+ Links
- openURL('https://wellnuo.com')}
/>
-
- openURL('https://docs.wellnuo.com')}
/>
-
- openURL('https://twitter.com/wellnuo')}
+ openURL('https://wellnuo.com/privacy')}
+ />
+ openURL('https://wellnuo.com/terms')}
+ last
/>
{/* Footer */}
-
- © 2024 WellNuo Inc.
- All rights reserved.
-
- Made with ❤️ for families worldwide
-
-
+ © 2026 WellNuo Inc.
);
}
+function FeatureItem({ icon, title, subtitle, last }: {
+ icon: string;
+ title: string;
+ subtitle: string;
+ last?: boolean;
+}) {
+ return (
+
+
+
+
+
+ {title}
+ {subtitle}
+
+
+ );
+}
+
+function LinkItem({ title, onPress, last }: {
+ title: string;
+ onPress: () => void;
+ last?: boolean;
+}) {
+ return (
+
+ {title}
+
+
+ );
+}
+
const styles = StyleSheet.create({
container: {
flex: 1,
- backgroundColor: AppColors.surface,
+ backgroundColor: '#F2F2F7',
},
- heroSection: {
+ scrollContent: {
+ paddingBottom: 40,
+ },
+ header: {
alignItems: 'center',
- paddingVertical: Spacing.xl,
- backgroundColor: AppColors.background,
+ paddingVertical: 32,
+ backgroundColor: '#FFFFFF',
},
- logoImage: {
- width: 180,
- height: 100,
- marginBottom: Spacing.sm,
+ logo: {
+ width: 80,
+ height: 80,
},
- appTagline: {
- fontSize: FontSizes.base,
- color: AppColors.textSecondary,
- marginTop: Spacing.xs,
+ appName: {
+ fontSize: 28,
+ fontWeight: '700',
+ color: '#000000',
+ marginTop: 16,
+ },
+ tagline: {
+ fontSize: 15,
+ color: '#8E8E93',
+ marginTop: 4,
+ },
+ version: {
+ fontSize: 13,
+ color: '#C7C7CC',
+ marginTop: 8,
},
section: {
- marginTop: Spacing.md,
+ marginTop: 24,
},
sectionTitle: {
- fontSize: FontSizes.sm,
+ fontSize: 13,
fontWeight: '600',
- color: AppColors.textSecondary,
- paddingHorizontal: Spacing.lg,
- paddingVertical: Spacing.sm,
+ color: '#8E8E93',
textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ marginLeft: 16,
+ marginBottom: 8,
},
card: {
- backgroundColor: AppColors.background,
- paddingVertical: Spacing.sm,
+ backgroundColor: '#FFFFFF',
+ marginHorizontal: 16,
+ borderRadius: 12,
},
- infoRow: {
- flexDirection: 'row',
- 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,
+ cardText: {
+ fontSize: 15,
lineHeight: 22,
- paddingHorizontal: Spacing.lg,
- paddingVertical: Spacing.sm,
+ color: '#000000',
+ padding: 16,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
- paddingVertical: Spacing.sm,
- paddingHorizontal: Spacing.lg,
+ padding: 14,
},
featureIcon: {
- width: 40,
- height: 40,
- borderRadius: BorderRadius.md,
- justifyContent: 'center',
+ width: 36,
+ height: 36,
+ borderRadius: 8,
+ backgroundColor: 'rgba(10, 132, 255, 0.1)',
alignItems: 'center',
+ justifyContent: 'center',
},
featureContent: {
flex: 1,
- marginLeft: Spacing.md,
+ marginLeft: 12,
},
featureTitle: {
- fontSize: FontSizes.base,
+ fontSize: 16,
fontWeight: '500',
- color: AppColors.textPrimary,
+ color: '#000000',
},
- featureDescription: {
- fontSize: FontSizes.xs,
- color: AppColors.textMuted,
+ featureSubtitle: {
+ fontSize: 13,
+ color: '#8E8E93',
marginTop: 2,
},
- linkRow: {
+ linkItem: {
flexDirection: 'row',
alignItems: 'center',
- paddingVertical: Spacing.md,
- paddingHorizontal: Spacing.lg,
+ justifyContent: 'space-between',
+ padding: 14,
+ paddingHorizontal: 16,
},
- linkText: {
- flex: 1,
- fontSize: FontSizes.base,
- color: AppColors.primary,
- marginLeft: Spacing.md,
+ linkTitle: {
+ fontSize: 16,
+ color: '#000000',
},
- linkDivider: {
- height: 1,
- backgroundColor: AppColors.border,
- marginLeft: Spacing.lg + 20 + Spacing.md,
+ itemBorder: {
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: '#E5E5EA',
},
footer: {
- alignItems: 'center',
- paddingVertical: Spacing.xl,
- paddingBottom: Spacing.xxl,
- },
- 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,
+ textAlign: 'center',
+ fontSize: 13,
+ color: '#8E8E93',
+ marginTop: 32,
},
});
diff --git a/backend/src/routes/beneficiaries.js b/backend/src/routes/beneficiaries.js
index 22b5843..ff1461d 100644
--- a/backend/src/routes/beneficiaries.js
+++ b/backend/src/routes/beneficiaries.js
@@ -1,8 +1,61 @@
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
+const Stripe = require('stripe');
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
*/
@@ -118,13 +171,8 @@ router.get('/:id', async (req, res) => {
return res.status(404).json({ error: 'Beneficiary not found' });
}
- // Get subscription for this beneficiary
- const { data: subscription } = await supabase
- .from('subscriptions')
- .select('*')
- .eq('beneficiary_id', beneficiaryId)
- .eq('user_id', userId)
- .single();
+ // Get subscription status from Stripe (source of truth)
+ const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id);
// Get orders for this beneficiary
const { data: orders } = await supabase
@@ -148,11 +196,7 @@ router.get('/:id', async (req, res) => {
country: beneficiary.address_country
},
role: access.role,
- subscription: subscription ? {
- plan: subscription.plan,
- status: subscription.status,
- currentPeriodEnd: subscription.current_period_end
- } : { plan: 'free', status: 'active' },
+ subscription: subscription,
orders: orders || []
});
diff --git a/backend/src/routes/stripe.js b/backend/src/routes/stripe.js
index 9f0dbbd..3c57709 100644
--- a/backend/src/routes/stripe.js
+++ b/backend/src/routes/stripe.js
@@ -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 checkout session details (for success page)
diff --git a/components/SubscriptionPayment.tsx b/components/SubscriptionPayment.tsx
index b03e90f..f2a4a54 100644
--- a/components/SubscriptionPayment.tsx
+++ b/components/SubscriptionPayment.tsx
@@ -8,7 +8,6 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { usePaymentSheet } from '@stripe/stripe-react-native';
-import { useAuth } from '@/contexts/AuthContext';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useToast } from '@/components/ui/Toast';
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
@@ -26,7 +25,6 @@ interface SubscriptionPaymentProps {
export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }: SubscriptionPaymentProps) {
const [isProcessing, setIsProcessing] = useState(false);
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
- const { user } = useAuth();
const { updateLocalBeneficiary } = useBeneficiary();
const toast = useToast();
@@ -36,40 +34,50 @@ export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }:
setIsProcessing(true);
try {
- // 1. Create Payment Sheet on our server
- const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
+ // 1. Create subscription payment sheet via new Stripe Subscriptions API
+ const response = await fetch(`${STRIPE_API_URL}/create-subscription-payment-sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
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,
- beneficiaryName: beneficiary.name,
- },
+ beneficiaryId: beneficiary.id,
}),
});
const data = await response.json();
- if (!data.paymentIntent) {
- throw new Error(data.error || 'Failed to create payment sheet');
+ // Check if already subscribed
+ 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
const { error: initError } = await initPaymentSheet({
merchantDisplayName: 'WellNuo',
- paymentIntentClientSecret: data.paymentIntent,
+ paymentIntentClientSecret: data.clientSecret,
customerId: data.customer,
customerEphemeralKeySecret: data.ephemeralKey,
- defaultBillingDetails: {
- email: user?.email || '',
- },
returnURL: 'wellnuo://stripe-redirect',
applePay: {
merchantCountryCode: 'US',
@@ -95,15 +103,17 @@ export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }:
throw new Error(presentError.message);
}
- // 4. Payment successful! Save subscription to beneficiary
- const now = new Date();
- const endDate = new Date(now);
- endDate.setMonth(endDate.getMonth() + 1); // 1 month subscription
+ // 4. Payment successful! Subscription is now active in Stripe
+ // Fetch the current status from Stripe to confirm
+ const statusResponse = await fetch(
+ `${STRIPE_API_URL}/subscription-status/${beneficiary.id}`
+ );
+ const statusData = await statusResponse.json();
+ // Update local state with data from Stripe
const newSubscription: BeneficiarySubscription = {
- status: 'active',
- startDate: now.toISOString(),
- endDate: endDate.toISOString(),
+ status: statusData.status === 'active' ? 'active' : 'none',
+ endDate: statusData.currentPeriodEnd,
planType: 'monthly',
price: SUBSCRIPTION_PRICE,
};
diff --git a/hooks/useNavigationFlow.ts b/hooks/useNavigationFlow.ts
new file mode 100644
index 0000000..cc1fdad
--- /dev/null
+++ b/hooks/useNavigationFlow.ts
@@ -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,
+ 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;
diff --git a/services/NavigationController.ts b/services/NavigationController.ts
new file mode 100644
index 0000000..6a3d647
--- /dev/null
+++ b/services/NavigationController.ts
@@ -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;
+}
+
+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;