From 20be9a94c2e63bdbe1f1ca97a13f966f35362c88 Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 4 Jan 2026 12:53:38 -0800 Subject: [PATCH] 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 --- CLAUDE.md | 136 ++++++- app/(auth)/activate.tsx | 310 +++------------ app/(tabs)/beneficiaries/[id]/index.tsx | 13 +- .../beneficiaries/[id]/subscription.tsx | 11 +- app/(tabs)/profile/about.tsx | 364 +++++++---------- backend/src/routes/beneficiaries.js | 68 +++- backend/src/routes/stripe.js | 363 +++++++++++++++++ components/SubscriptionPayment.tsx | 62 +-- hooks/useNavigationFlow.ts | 179 +++++++++ services/NavigationController.ts | 375 ++++++++++++++++++ 10 files changed, 1354 insertions(+), 527 deletions(-) create mode 100644 hooks/useNavigationFlow.ts create mode 100644 services/NavigationController.ts 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;