WIP: Navigation controller, subscription flow, and various improvements
- Add NavigationController for centralized routing logic - Add useNavigationFlow hook for easy usage in components - Update subscription flow with Stripe integration - Simplify activate.tsx - Update beneficiaries and profile screens - Update CLAUDE.md with navigation documentation
This commit is contained in:
parent
1d93311b12
commit
20be9a94c2
136
CLAUDE.md
136
CLAUDE.md
@ -12,7 +12,7 @@
|
|||||||
- Every CRUD operation goes through the WellNuo API
|
- Every CRUD operation goes through the WellNuo API
|
||||||
|
|
||||||
2. **Single Source of Truth: WellNuo Backend**
|
2. **Single Source of Truth: WellNuo Backend**
|
||||||
- All beneficiary data is stored in Supabase via the backend
|
- All beneficiary data is stored in PostgreSQL (hosted on eluxnetworks.net)
|
||||||
- Changes are immediately persisted to the server
|
- Changes are immediately persisted to the server
|
||||||
- App always fetches fresh data on screen focus
|
- App always fetches fresh data on screen focus
|
||||||
|
|
||||||
@ -28,10 +28,11 @@ Base URL: `https://wellnuo.smartlaunchhub.com/api`
|
|||||||
| POST | `/me/beneficiaries` | Create new beneficiary |
|
| POST | `/me/beneficiaries` | Create new beneficiary |
|
||||||
| PATCH | `/me/beneficiaries/:id` | Update beneficiary |
|
| PATCH | `/me/beneficiaries/:id` | Update beneficiary |
|
||||||
| DELETE | `/me/beneficiaries/:id` | Remove beneficiary access |
|
| DELETE | `/me/beneficiaries/:id` | Remove beneficiary access |
|
||||||
| GET | `/me/profile` | Get current user profile |
|
| POST | `/me/beneficiaries/:id/activate` | Activate device for beneficiary |
|
||||||
| PATCH | `/me/profile` | Update user profile (firstName, lastName, phone) |
|
| GET | `/auth/me` | Get current user profile with beneficiaries |
|
||||||
|
| PATCH | `/auth/profile` | Update user profile (firstName, lastName, phone) |
|
||||||
|
|
||||||
Authentication: Bearer token (JWT from `/auth/login`)
|
Authentication: Bearer token (JWT from `/auth/verify-otp`)
|
||||||
|
|
||||||
#### Legacy API (WebView Dashboard)
|
#### Legacy API (WebView Dashboard)
|
||||||
Base URL: `https://eluxnetworks.net/function/well-api/api`
|
Base URL: `https://eluxnetworks.net/function/well-api/api`
|
||||||
@ -43,10 +44,10 @@ Used only for:
|
|||||||
### Data Flow
|
### Data Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
|
┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
│ React App │ ──▶ │ WellNuo Backend │ ──▶ │ Supabase │
|
│ React App │ ──▶ │ WellNuo Backend │ ──▶ │ PostgreSQL │
|
||||||
│ (Expo) │ ◀── │ (Express.js) │ ◀── │ (Postgres) │
|
│ (Expo) │ ◀── │ (Express.js) │ ◀── │ (eluxnetworks.net)│
|
||||||
└─────────────┘ └──────────────────┘ └─────────────┘
|
└─────────────┘ └──────────────────┘ └──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Schema
|
### Database Schema
|
||||||
@ -69,12 +70,127 @@ Used only for:
|
|||||||
### What TO do
|
### What TO do
|
||||||
|
|
||||||
- ✅ Always fetch fresh data via `api.getAllBeneficiaries()` or `api.getWellNuoBeneficiary(id)`
|
- ✅ Always fetch fresh data via `api.getAllBeneficiaries()` or `api.getWellNuoBeneficiary(id)`
|
||||||
- ✅ Get user profile via `api.getProfile()`, update via `api.updateProfile()`
|
- ✅ Get user profile via `api.getMe()`, update via `api.updateProfile()` (calls `/auth/profile`)
|
||||||
- ✅ Use `useFocusEffect` to reload data when screen gains focus
|
- ✅ Use `useFocusEffect` to reload data when screen gains focus
|
||||||
- ✅ Handle loading and error states for all API calls
|
- ✅ Handle loading and error states for all API calls
|
||||||
- ✅ Show appropriate feedback on successful operations
|
- ✅ Show appropriate feedback on successful operations
|
||||||
- ✅ Only store in SecureStore: `accessToken`, `userId`, `userEmail` (technical auth data)
|
- ✅ Only store in SecureStore: `accessToken`, `userId`, `userEmail` (technical auth data)
|
||||||
|
|
||||||
|
## Navigation Controller (Centralized Routing)
|
||||||
|
|
||||||
|
All navigation decisions are centralized in `services/NavigationController.ts`.
|
||||||
|
|
||||||
|
**IMPORTANT: NavigationController is NOT automatic middleware!**
|
||||||
|
- It does NOT intercept or control all navigation automatically
|
||||||
|
- It's a utility that must be EXPLICITLY called at specific points in the app
|
||||||
|
- You must manually call `nav.navigateAfterLogin()`, `nav.navigateAfterAddBeneficiary()`, etc.
|
||||||
|
- Regular navigation (e.g., `router.push('/profile')`) works independently
|
||||||
|
|
||||||
|
### When to use NavigationController:
|
||||||
|
- ✅ After OTP verification (login flow)
|
||||||
|
- ✅ After creating a new beneficiary
|
||||||
|
- ✅ After completing purchase/demo selection
|
||||||
|
- ✅ When you need to determine the "correct next screen" based on user state
|
||||||
|
|
||||||
|
### When NOT needed:
|
||||||
|
- ❌ Simple screen-to-screen navigation (just use `router.push()`)
|
||||||
|
- ❌ Tab navigation (handled by Expo Router)
|
||||||
|
- ❌ Back navigation (handled automatically)
|
||||||
|
|
||||||
|
### Key Files
|
||||||
|
- `services/NavigationController.ts` - All routing logic and conditions
|
||||||
|
- `hooks/useNavigationFlow.ts` - React hook for easy usage in components
|
||||||
|
|
||||||
|
### Navigation Flow After Login
|
||||||
|
|
||||||
|
```
|
||||||
|
User enters OTP code
|
||||||
|
↓
|
||||||
|
Has firstName in profile?
|
||||||
|
│
|
||||||
|
NO → /(auth)/enter-name
|
||||||
|
│
|
||||||
|
YES → Has beneficiaries?
|
||||||
|
│
|
||||||
|
NO → /(auth)/add-loved-one
|
||||||
|
│
|
||||||
|
YES → Check equipment status of first beneficiary
|
||||||
|
├── none → /(auth)/purchase
|
||||||
|
├── ordered/shipped → /(tabs)/beneficiaries/:id/equipment
|
||||||
|
├── delivered → /(auth)/activate
|
||||||
|
└── active/demo → /(tabs)/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation After Adding Beneficiary
|
||||||
|
|
||||||
|
```
|
||||||
|
Beneficiary created in DB
|
||||||
|
↓
|
||||||
|
User already has WellNuo devices?
|
||||||
|
│
|
||||||
|
NO → /(auth)/purchase (buy equipment)
|
||||||
|
│
|
||||||
|
YES → /(auth)/activate (connect existing device)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation After Purchase
|
||||||
|
|
||||||
|
```
|
||||||
|
Payment completed / Demo selected
|
||||||
|
↓
|
||||||
|
Demo mode?
|
||||||
|
│
|
||||||
|
YES → /(auth)/activate (immediate activation)
|
||||||
|
│
|
||||||
|
NO → /(tabs)/beneficiaries/:id/equipment (track delivery)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useNavigationFlow } from '@/hooks/useNavigationFlow';
|
||||||
|
|
||||||
|
function MyScreen() {
|
||||||
|
const nav = useNavigationFlow();
|
||||||
|
|
||||||
|
// After login - automatically determines correct screen
|
||||||
|
nav.navigateAfterLogin(profile, beneficiaries);
|
||||||
|
|
||||||
|
// After adding beneficiary
|
||||||
|
nav.navigateAfterAddBeneficiary(42, hasExistingDevices);
|
||||||
|
|
||||||
|
// After purchase
|
||||||
|
nav.navigateAfterPurchase(42, { demo: true });
|
||||||
|
|
||||||
|
// Quick shortcuts
|
||||||
|
nav.goToDashboard();
|
||||||
|
nav.goToPurchase(beneficiaryId);
|
||||||
|
nav.goToActivate(beneficiaryId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Routes (ROUTES constant)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ROUTES.AUTH.LOGIN // /(auth)/login
|
||||||
|
ROUTES.AUTH.VERIFY_OTP // /(auth)/verify-otp
|
||||||
|
ROUTES.AUTH.ENTER_NAME // /(auth)/enter-name
|
||||||
|
ROUTES.AUTH.ADD_LOVED_ONE // /(auth)/add-loved-one
|
||||||
|
ROUTES.AUTH.PURCHASE // /(auth)/purchase
|
||||||
|
ROUTES.AUTH.ACTIVATE // /(auth)/activate
|
||||||
|
|
||||||
|
ROUTES.TABS.DASHBOARD // /(tabs)/dashboard
|
||||||
|
ROUTES.TABS.BENEFICIARIES // /(tabs)/beneficiaries
|
||||||
|
ROUTES.TABS.CHAT // /(tabs)/chat
|
||||||
|
ROUTES.TABS.VOICE // /(tabs)/voice
|
||||||
|
ROUTES.TABS.PROFILE // /(tabs)/profile
|
||||||
|
|
||||||
|
ROUTES.BENEFICIARY.DETAIL(id) // /(tabs)/beneficiaries/:id
|
||||||
|
ROUTES.BENEFICIARY.SUBSCRIPTION(id) // /(tabs)/beneficiaries/:id/subscription
|
||||||
|
ROUTES.BENEFICIARY.EQUIPMENT(id) // /(tabs)/beneficiaries/:id/equipment
|
||||||
|
ROUTES.BENEFICIARY.SHARE(id) // /(tabs)/beneficiaries/:id/share
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Server Location
|
### Server Location
|
||||||
@ -83,6 +199,8 @@ Used only for:
|
|||||||
|
|
||||||
### Key Files
|
### Key Files
|
||||||
- `services/api.ts` - All API methods
|
- `services/api.ts` - All API methods
|
||||||
|
- `services/NavigationController.ts` - Centralized routing logic
|
||||||
|
- `hooks/useNavigationFlow.ts` - Navigation hook for components
|
||||||
- `contexts/BeneficiaryContext.tsx` - Beneficiary state management (current selection only)
|
- `contexts/BeneficiaryContext.tsx` - Beneficiary state management (current selection only)
|
||||||
- `app/(tabs)/beneficiaries/` - Beneficiary screens
|
- `app/(tabs)/beneficiaries/` - Beneficiary screens
|
||||||
|
|
||||||
|
|||||||
@ -13,27 +13,19 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
|
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
|
||||||
export default function ActivateScreen() {
|
export default function ActivateScreen() {
|
||||||
// Get params - lovedOneName from purchase flow, beneficiaryId from existing beneficiary
|
// Get params - beneficiaryId is REQUIRED (created in add-loved-one.tsx)
|
||||||
|
// lovedOneName is for display purposes
|
||||||
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
|
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
|
||||||
|
const beneficiaryId = params.beneficiaryId ? parseInt(params.beneficiaryId, 10) : null;
|
||||||
|
const lovedOneName = params.lovedOneName || '';
|
||||||
|
|
||||||
const [activationCode, setActivationCode] = useState('');
|
const [activationCode, setActivationCode] = useState('');
|
||||||
const [isActivating, setIsActivating] = useState(false);
|
const [isActivating, setIsActivating] = useState(false);
|
||||||
const [step, setStep] = useState<'code' | 'beneficiary' | 'complete'>('code');
|
const [step, setStep] = useState<'code' | 'complete'>('code');
|
||||||
// Pre-fill beneficiary name from params if available
|
|
||||||
const [beneficiaryName, setBeneficiaryName] = useState(params.lovedOneName || '');
|
|
||||||
|
|
||||||
const { localBeneficiaries } = useBeneficiary();
|
|
||||||
|
|
||||||
// Check if we're activating for an existing beneficiary
|
|
||||||
const existingBeneficiaryId = params.beneficiaryId ? parseInt(params.beneficiaryId, 10) : null;
|
|
||||||
const existingBeneficiary = existingBeneficiaryId
|
|
||||||
? localBeneficiaries.find(b => b.id === existingBeneficiaryId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Demo serial for testing without real hardware
|
// Demo serial for testing without real hardware
|
||||||
const DEMO_SERIAL = 'DEMO-00000';
|
const DEMO_SERIAL = 'DEMO-00000';
|
||||||
@ -55,49 +47,26 @@ export default function ActivateScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beneficiary ID is required - was created in add-loved-one.tsx
|
||||||
|
if (!beneficiaryId) {
|
||||||
|
Alert.alert('Error', 'Beneficiary not found. Please go back and try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsActivating(true);
|
setIsActivating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// If we have an existing beneficiary, activate via API (persists to server!)
|
// Call API to activate - sets has_existing_devices = true on backend
|
||||||
if (existingBeneficiaryId && existingBeneficiary) {
|
const response = await api.activateBeneficiary(beneficiaryId, code);
|
||||||
// Call API to persist activation - this creates order with 'installed' status
|
|
||||||
const response = await api.activateBeneficiary(existingBeneficiaryId, code);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
Alert.alert('Error', response.error?.message || 'Failed to activate equipment');
|
Alert.alert('Error', response.error?.message || 'Failed to activate equipment');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBeneficiaryName(existingBeneficiary.name);
|
// Mark onboarding as completed
|
||||||
setStep('complete');
|
|
||||||
} else {
|
|
||||||
// Creating new beneficiary - If name was already provided from add-loved-one screen, create via API
|
|
||||||
if (params.lovedOneName && params.lovedOneName.trim()) {
|
|
||||||
// Step 1: Create beneficiary on server
|
|
||||||
const createResult = await api.createBeneficiary({
|
|
||||||
firstName: params.lovedOneName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createResult.ok || !createResult.data) {
|
|
||||||
Alert.alert('Error', createResult.error?.message || 'Failed to create beneficiary');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Activate the newly created beneficiary (sets has_existing_devices = true)
|
|
||||||
const activateResult = await api.activateBeneficiary(createResult.data.id, code);
|
|
||||||
|
|
||||||
if (!activateResult.ok) {
|
|
||||||
Alert.alert('Error', activateResult.error?.message || 'Failed to activate equipment');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await api.setOnboardingCompleted(true);
|
await api.setOnboardingCompleted(true);
|
||||||
setStep('complete');
|
setStep('complete');
|
||||||
} else {
|
|
||||||
// No name provided, show beneficiary form
|
|
||||||
setStep('beneficiary');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to activate:', error);
|
console.error('Failed to activate:', error);
|
||||||
Alert.alert('Error', 'Failed to activate kit. Please try again.');
|
Alert.alert('Error', 'Failed to activate kit. Please try again.');
|
||||||
@ -106,52 +75,11 @@ export default function ActivateScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddBeneficiary = async () => {
|
|
||||||
if (!beneficiaryName.trim()) {
|
|
||||||
Alert.alert('Error', 'Please enter beneficiary name');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsActivating(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const code = activationCode.trim().toUpperCase();
|
|
||||||
|
|
||||||
// Step 1: Create beneficiary on server
|
|
||||||
const createResult = await api.createBeneficiary({
|
|
||||||
firstName: beneficiaryName.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!createResult.ok || !createResult.data) {
|
|
||||||
Alert.alert('Error', createResult.error?.message || 'Failed to create beneficiary');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Activate the newly created beneficiary (sets has_existing_devices = true)
|
|
||||||
const activateResult = await api.activateBeneficiary(createResult.data.id, code);
|
|
||||||
|
|
||||||
if (!activateResult.ok) {
|
|
||||||
Alert.alert('Error', activateResult.error?.message || 'Failed to activate equipment');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark onboarding as completed
|
|
||||||
await api.setOnboardingCompleted(true);
|
|
||||||
setStep('complete');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to add beneficiary:', error);
|
|
||||||
Alert.alert('Error', 'Failed to add beneficiary. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsActivating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
// If updating existing beneficiary, go back to their detail page
|
// Navigate to beneficiary detail page after activation
|
||||||
if (existingBeneficiaryId) {
|
if (beneficiaryId) {
|
||||||
router.replace(`/(tabs)/beneficiaries/${existingBeneficiaryId}`);
|
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}` as const);
|
||||||
} else {
|
} else {
|
||||||
// Navigate to main app
|
|
||||||
router.replace('/(tabs)');
|
router.replace('/(tabs)');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -163,16 +91,10 @@ export default function ActivateScreen() {
|
|||||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
{existingBeneficiary ? (
|
|
||||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
<Text style={styles.title}>Connect Sensors</Text>
|
||||||
<View style={styles.placeholder} />
|
|
||||||
)}
|
|
||||||
<Text style={styles.title}>
|
|
||||||
{existingBeneficiary ? 'Connect Sensors' : 'Activate Kit'}
|
|
||||||
</Text>
|
|
||||||
<View style={styles.placeholder} />
|
<View style={styles.placeholder} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -183,9 +105,9 @@ export default function ActivateScreen() {
|
|||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<Text style={styles.instructions}>
|
<Text style={styles.instructions}>
|
||||||
{existingBeneficiary
|
{lovedOneName
|
||||||
? `Connect sensors for ${existingBeneficiary.name}`
|
? `Connect sensors for ${lovedOneName}`
|
||||||
: 'Enter the activation code from your WellNuo Starter Kit packaging'}
|
: 'Enter the serial number from your sensors'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
@ -222,76 +144,12 @@ export default function ActivateScreen() {
|
|||||||
<Text style={styles.primaryButtonText}>Activate</Text>
|
<Text style={styles.primaryButtonText}>Activate</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Skip for now - only show for new onboarding, not for existing beneficiary */}
|
|
||||||
{!existingBeneficiary && (
|
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={handleComplete}>
|
|
||||||
<Text style={styles.skipButtonText}>Skip for now</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Add beneficiary
|
// Step 2: Complete
|
||||||
if (step === 'beneficiary') {
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
||||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
|
||||||
{/* Header */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<TouchableOpacity style={styles.backButton} onPress={() => setStep('code')}>
|
|
||||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.title}>Add Beneficiary</Text>
|
|
||||||
<View style={styles.placeholder} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Icon */}
|
|
||||||
<View style={styles.iconContainer}>
|
|
||||||
<Ionicons name="person-add" size={64} color={AppColors.primary} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Instructions */}
|
|
||||||
<Text style={styles.instructions}>
|
|
||||||
Who will you be monitoring with this kit?
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Name Input */}
|
|
||||||
<View style={styles.inputGroup}>
|
|
||||||
<Text style={styles.inputLabel}>Full Name</Text>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
value={beneficiaryName}
|
|
||||||
onChangeText={setBeneficiaryName}
|
|
||||||
placeholder="e.g., Grandma Julia"
|
|
||||||
placeholderTextColor={AppColors.textMuted}
|
|
||||||
autoCapitalize="words"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Continue Button */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.primaryButton, isActivating && styles.buttonDisabled]}
|
|
||||||
onPress={handleAddBeneficiary}
|
|
||||||
disabled={isActivating}
|
|
||||||
>
|
|
||||||
{isActivating ? (
|
|
||||||
<ActivityIndicator color={AppColors.white} />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.primaryButtonText}>Continue</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Complete
|
|
||||||
const displayName = beneficiaryName || existingBeneficiary?.name || params.lovedOneName;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
@ -301,32 +159,15 @@ export default function ActivateScreen() {
|
|||||||
<Ionicons name="checkmark-circle" size={80} color={AppColors.success} />
|
<Ionicons name="checkmark-circle" size={80} color={AppColors.success} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.successTitle}>
|
<Text style={styles.successTitle}>Sensors Connected!</Text>
|
||||||
{existingBeneficiary ? 'Sensors Connected!' : 'Kit Activated!'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.successMessage}>
|
<Text style={styles.successMessage}>
|
||||||
{existingBeneficiary
|
Your sensors have been successfully connected for{' '}
|
||||||
? `Sensors have been connected for `
|
<Text style={styles.beneficiaryHighlight}>{lovedOneName || 'your loved one'}</Text>
|
||||||
: `Your WellNuo kit has been successfully activated for `}
|
|
||||||
<Text style={styles.beneficiaryHighlight}>{displayName}</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Next Steps */}
|
{/* Next Steps */}
|
||||||
<View style={styles.nextSteps}>
|
<View style={styles.nextSteps}>
|
||||||
<Text style={styles.nextStepsTitle}>Next Steps:</Text>
|
<Text style={styles.nextStepsTitle}>Next Steps:</Text>
|
||||||
{existingBeneficiary ? (
|
|
||||||
<>
|
|
||||||
<View style={styles.stepItem}>
|
|
||||||
<Text style={styles.stepNumber}>1</Text>
|
|
||||||
<Text style={styles.stepText}>Connect the hub to WiFi</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.stepItem}>
|
|
||||||
<Text style={styles.stepNumber}>2</Text>
|
|
||||||
<Text style={styles.stepText}>Subscribe to start monitoring ($49/month)</Text>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<View style={styles.stepItem}>
|
<View style={styles.stepItem}>
|
||||||
<Text style={styles.stepNumber}>1</Text>
|
<Text style={styles.stepNumber}>1</Text>
|
||||||
<Text style={styles.stepText}>Place sensors in your loved one's home</Text>
|
<Text style={styles.stepText}>Place sensors in your loved one's home</Text>
|
||||||
@ -339,16 +180,12 @@ export default function ActivateScreen() {
|
|||||||
<Text style={styles.stepNumber}>3</Text>
|
<Text style={styles.stepNumber}>3</Text>
|
||||||
<Text style={styles.stepText}>Subscribe to start monitoring ($49/month)</Text>
|
<Text style={styles.stepText}>Subscribe to start monitoring ($49/month)</Text>
|
||||||
</View>
|
</View>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Complete Button */}
|
{/* Complete Button */}
|
||||||
<TouchableOpacity style={styles.primaryButton} onPress={handleComplete}>
|
<TouchableOpacity style={styles.primaryButton} onPress={handleComplete}>
|
||||||
<Text style={styles.primaryButtonText}>
|
<Text style={styles.primaryButtonText}>Go to Dashboard</Text>
|
||||||
{existingBeneficiary ? 'Continue' : 'Go to Dashboard'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
@ -396,15 +233,6 @@ const styles = StyleSheet.create({
|
|||||||
inputContainer: {
|
inputContainer: {
|
||||||
marginBottom: Spacing.lg,
|
marginBottom: Spacing.lg,
|
||||||
},
|
},
|
||||||
inputGroup: {
|
|
||||||
marginBottom: Spacing.lg,
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
fontWeight: FontWeights.medium,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
marginBottom: Spacing.sm,
|
|
||||||
},
|
|
||||||
input: {
|
input: {
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: AppColors.surface,
|
||||||
borderRadius: BorderRadius.lg,
|
borderRadius: BorderRadius.lg,
|
||||||
@ -416,41 +244,6 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: AppColors.border,
|
borderColor: AppColors.border,
|
||||||
},
|
},
|
||||||
devNotice: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: Spacing.xl,
|
|
||||||
paddingVertical: Spacing.lg,
|
|
||||||
paddingHorizontal: Spacing.lg,
|
|
||||||
backgroundColor: `${AppColors.warning}10`,
|
|
||||||
borderRadius: BorderRadius.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: `${AppColors.warning}30`,
|
|
||||||
},
|
|
||||||
devNoticeTitle: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
fontWeight: FontWeights.semibold,
|
|
||||||
color: AppColors.warning,
|
|
||||||
marginTop: Spacing.sm,
|
|
||||||
marginBottom: Spacing.sm,
|
|
||||||
},
|
|
||||||
devNoticeText: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 20,
|
|
||||||
marginBottom: Spacing.md,
|
|
||||||
},
|
|
||||||
demoCodeButton: {
|
|
||||||
paddingVertical: Spacing.sm,
|
|
||||||
paddingHorizontal: Spacing.lg,
|
|
||||||
backgroundColor: AppColors.warning,
|
|
||||||
borderRadius: BorderRadius.md,
|
|
||||||
},
|
|
||||||
demoCodeButtonText: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
fontWeight: FontWeights.medium,
|
|
||||||
color: AppColors.white,
|
|
||||||
},
|
|
||||||
demoCodeLink: {
|
demoCodeLink: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -477,15 +270,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: FontWeights.semibold,
|
fontWeight: FontWeights.semibold,
|
||||||
color: AppColors.white,
|
color: AppColors.white,
|
||||||
},
|
},
|
||||||
skipButton: {
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingVertical: Spacing.lg,
|
|
||||||
},
|
|
||||||
skipButtonText: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
textDecorationLine: 'underline',
|
|
||||||
},
|
|
||||||
successContainer: {
|
successContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@ -48,11 +48,18 @@ type SetupState = 'loading' | 'awaiting_equipment' | 'no_devices' | 'no_subscrip
|
|||||||
// Stripe API
|
// Stripe API
|
||||||
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
||||||
|
|
||||||
// WebView Dashboard URL - uses test NDK account for demo data
|
// WebView Dashboard URL - opens specific deployment directly
|
||||||
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
|
// Format: /dashboard/{deployment_id} skips the user list and shows the dashboard
|
||||||
|
const getDashboardUrl = (deploymentId?: number) => {
|
||||||
|
const baseUrl = 'https://react.eluxnetworks.net/dashboard';
|
||||||
|
// Default to Ferdinand's deployment (21) if no specific deployment
|
||||||
|
return deploymentId ? `${baseUrl}/${deploymentId}` : `${baseUrl}/21`;
|
||||||
|
};
|
||||||
// Test credentials for WebView - anandk account has real sensor data
|
// Test credentials for WebView - anandk account has real sensor data
|
||||||
const TEST_NDK_USER = 'anandk';
|
const TEST_NDK_USER = 'anandk';
|
||||||
const TEST_NDK_PASSWORD = 'anandk_8';
|
const TEST_NDK_PASSWORD = 'anandk_8';
|
||||||
|
// Ferdinand's default deployment ID (has sensor data)
|
||||||
|
const FERDINAND_DEPLOYMENT_ID = 21;
|
||||||
|
|
||||||
// Starter Kit info
|
// Starter Kit info
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
@ -697,7 +704,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
isWebViewReady && authToken ? (
|
isWebViewReady && authToken ? (
|
||||||
<WebView
|
<WebView
|
||||||
ref={webViewRef}
|
ref={webViewRef}
|
||||||
source={{ uri: DASHBOARD_URL }}
|
source={{ uri: getDashboardUrl(FERDINAND_DEPLOYMENT_ID) }}
|
||||||
style={styles.webView}
|
style={styles.webView}
|
||||||
javaScriptEnabled={true}
|
javaScriptEnabled={true}
|
||||||
domStorageEnabled={true}
|
domStorageEnabled={true}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { usePaymentSheet } from '@stripe/stripe-react-native';
|
|||||||
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
|
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { api } from '@/services/api';
|
||||||
import type { Beneficiary, BeneficiarySubscription } from '@/types';
|
import type { Beneficiary, BeneficiarySubscription } from '@/types';
|
||||||
|
|
||||||
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
||||||
@ -48,7 +49,7 @@ export default function SubscriptionScreen() {
|
|||||||
|
|
||||||
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
|
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { getBeneficiaryById, updateLocalBeneficiary } = useBeneficiary();
|
const { updateLocalBeneficiary } = useBeneficiary();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadBeneficiary();
|
loadBeneficiary();
|
||||||
@ -58,8 +59,12 @@ export default function SubscriptionScreen() {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await getBeneficiaryById(id);
|
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
|
||||||
setBeneficiary(data);
|
if (response.ok && response.data) {
|
||||||
|
setBeneficiary(response.data);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load beneficiary:', response.error);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load beneficiary:', error);
|
console.error('Failed to load beneficiary:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -10,39 +10,8 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
import { PageHeader } from '@/components/PageHeader';
|
||||||
|
|
||||||
interface InfoRowProps {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function InfoRow({ label, value }: InfoRowProps) {
|
|
||||||
return (
|
|
||||||
<View style={styles.infoRow}>
|
|
||||||
<Text style={styles.infoLabel}>{label}</Text>
|
|
||||||
<Text style={styles.infoValue}>{value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LinkRowProps {
|
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
title: string;
|
|
||||||
onPress: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LinkRow({ icon, title, onPress }: LinkRowProps) {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity style={styles.linkRow} onPress={onPress}>
|
|
||||||
<Ionicons name={icon} size={20} color={AppColors.primary} />
|
|
||||||
<Text style={styles.linkText}>{title}</Text>
|
|
||||||
<Ionicons name="open-outline" size={16} color={AppColors.textMuted} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AboutScreen() {
|
export default function AboutScreen() {
|
||||||
const openURL = (url: string) => {
|
const openURL = (url: string) => {
|
||||||
Linking.openURL(url).catch(() => {});
|
Linking.openURL(url).catch(() => {});
|
||||||
@ -50,255 +19,228 @@ export default function AboutScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
<PageHeader title="About WellNuo" />
|
<PageHeader title="About" />
|
||||||
<ScrollView showsVerticalScrollIndicator={false}>
|
<ScrollView
|
||||||
{/* App Logo & Name */}
|
showsVerticalScrollIndicator={false}
|
||||||
<View style={styles.heroSection}>
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
<Image
|
<Image
|
||||||
source={require('@/assets/images/icon.png')}
|
source={require('@/assets/logo.png')}
|
||||||
style={styles.logoImage}
|
style={styles.logo}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
<Text style={styles.appTagline}>Caring for Those Who Matter Most</Text>
|
<Text style={styles.appName}>WellNuo</Text>
|
||||||
</View>
|
<Text style={styles.tagline}>Caring for those who matter most</Text>
|
||||||
|
<Text style={styles.version}>Version 1.0.0</Text>
|
||||||
{/* Version Info */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>App Information</Text>
|
|
||||||
<View style={styles.card}>
|
|
||||||
<InfoRow label="Version" value="1.0.0" />
|
|
||||||
<View style={styles.infoDivider} />
|
|
||||||
<InfoRow label="Build" value="2024.12.001" />
|
|
||||||
<View style={styles.infoDivider} />
|
|
||||||
<InfoRow label="Platform" value="iOS / Android" />
|
|
||||||
<View style={styles.infoDivider} />
|
|
||||||
<InfoRow label="SDK" value="Expo SDK 54" />
|
|
||||||
<View style={styles.infoDivider} />
|
|
||||||
<InfoRow label="Last Updated" value="December 2024" />
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>About</Text>
|
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<Text style={styles.description}>
|
<Text style={styles.cardText}>
|
||||||
WellNuo is a comprehensive elderly care monitoring application designed to help
|
WellNuo helps families stay connected with their loved ones through
|
||||||
families and caregivers stay connected with their loved ones. Using advanced
|
smart monitoring and AI-powered insights.
|
||||||
sensor technology and AI-powered analytics, WellNuo provides real-time insights
|
|
||||||
into daily activities, health patterns, and emergency situations.
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.description}>
|
|
||||||
Our mission is to bring peace of mind to families while preserving the independence
|
|
||||||
and dignity of elderly individuals.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Key Features</Text>
|
<Text style={styles.sectionTitle}>Features</Text>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<View style={styles.featureItem}>
|
<FeatureItem
|
||||||
<View style={[styles.featureIcon, { backgroundColor: '#DBEAFE' }]}>
|
icon="pulse"
|
||||||
<Ionicons name="pulse" size={20} color="#3B82F6" />
|
title="Activity Monitoring"
|
||||||
</View>
|
subtitle="24/7 wellness tracking"
|
||||||
<View style={styles.featureContent}>
|
/>
|
||||||
<Text style={styles.featureTitle}>Real-time Monitoring</Text>
|
<FeatureItem
|
||||||
<Text style={styles.featureDescription}>24/7 activity and wellness tracking</Text>
|
icon="notifications"
|
||||||
</View>
|
title="Smart Alerts"
|
||||||
</View>
|
subtitle="Instant emergency notifications"
|
||||||
<View style={styles.featureItem}>
|
/>
|
||||||
<View style={[styles.featureIcon, { backgroundColor: '#FEE2E2' }]}>
|
<FeatureItem
|
||||||
<Ionicons name="warning" size={20} color="#EF4444" />
|
icon="bar-chart"
|
||||||
</View>
|
title="Health Insights"
|
||||||
<View style={styles.featureContent}>
|
subtitle="AI-powered pattern analysis"
|
||||||
<Text style={styles.featureTitle}>Emergency Alerts</Text>
|
/>
|
||||||
<Text style={styles.featureDescription}>Instant notifications for falls and emergencies</Text>
|
<FeatureItem
|
||||||
</View>
|
icon="people"
|
||||||
</View>
|
title="Family Sharing"
|
||||||
<View style={styles.featureItem}>
|
subtitle="Coordinate care together"
|
||||||
<View style={[styles.featureIcon, { backgroundColor: '#D1FAE5' }]}>
|
last
|
||||||
<Ionicons name="analytics" size={20} color="#10B981" />
|
/>
|
||||||
</View>
|
|
||||||
<View style={styles.featureContent}>
|
|
||||||
<Text style={styles.featureTitle}>AI-Powered Insights</Text>
|
|
||||||
<Text style={styles.featureDescription}>Smart analysis of health patterns</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureItem}>
|
|
||||||
<View style={[styles.featureIcon, { backgroundColor: '#FEF3C7' }]}>
|
|
||||||
<Ionicons name="people" size={20} color="#F59E0B" />
|
|
||||||
</View>
|
|
||||||
<View style={styles.featureContent}>
|
|
||||||
<Text style={styles.featureTitle}>Family Coordination</Text>
|
|
||||||
<Text style={styles.featureDescription}>Share care with multiple caregivers</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Text style={styles.sectionTitle}>Resources</Text>
|
<Text style={styles.sectionTitle}>Links</Text>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
<LinkRow
|
<LinkItem
|
||||||
icon="globe-outline"
|
title="Website"
|
||||||
title="Visit Website"
|
|
||||||
onPress={() => openURL('https://wellnuo.com')}
|
onPress={() => openURL('https://wellnuo.com')}
|
||||||
/>
|
/>
|
||||||
<View style={styles.linkDivider} />
|
<LinkItem
|
||||||
<LinkRow
|
title="Help Center"
|
||||||
icon="document-text-outline"
|
|
||||||
title="Documentation"
|
|
||||||
onPress={() => openURL('https://docs.wellnuo.com')}
|
onPress={() => openURL('https://docs.wellnuo.com')}
|
||||||
/>
|
/>
|
||||||
<View style={styles.linkDivider} />
|
<LinkItem
|
||||||
<LinkRow
|
title="Privacy Policy"
|
||||||
icon="logo-twitter"
|
onPress={() => openURL('https://wellnuo.com/privacy')}
|
||||||
title="Follow on Twitter"
|
/>
|
||||||
onPress={() => openURL('https://twitter.com/wellnuo')}
|
<LinkItem
|
||||||
|
title="Terms of Service"
|
||||||
|
onPress={() => openURL('https://wellnuo.com/terms')}
|
||||||
|
last
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<View style={styles.footer}>
|
<Text style={styles.footer}>© 2026 WellNuo Inc.</Text>
|
||||||
<Text style={styles.copyright}>© 2024 WellNuo Inc.</Text>
|
|
||||||
<Text style={styles.footerText}>All rights reserved.</Text>
|
|
||||||
<Text style={styles.madeWith}>
|
|
||||||
Made with ❤️ for families worldwide
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FeatureItem({ icon, title, subtitle, last }: {
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.featureItem, !last && styles.itemBorder]}>
|
||||||
|
<View style={styles.featureIcon}>
|
||||||
|
<Ionicons name={icon as any} size={20} color="#0A84FF" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.featureContent}>
|
||||||
|
<Text style={styles.featureTitle}>{title}</Text>
|
||||||
|
<Text style={styles.featureSubtitle}>{subtitle}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkItem({ title, onPress, last }: {
|
||||||
|
title: string;
|
||||||
|
onPress: () => void;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.linkItem, !last && styles.itemBorder]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.6}
|
||||||
|
>
|
||||||
|
<Text style={styles.linkTitle}>{title}</Text>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color="#C7C7CC" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: AppColors.surface,
|
backgroundColor: '#F2F2F7',
|
||||||
},
|
},
|
||||||
heroSection: {
|
scrollContent: {
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: Spacing.xl,
|
paddingVertical: 32,
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: '#FFFFFF',
|
||||||
},
|
},
|
||||||
logoImage: {
|
logo: {
|
||||||
width: 180,
|
width: 80,
|
||||||
height: 100,
|
height: 80,
|
||||||
marginBottom: Spacing.sm,
|
|
||||||
},
|
},
|
||||||
appTagline: {
|
appName: {
|
||||||
fontSize: FontSizes.base,
|
fontSize: 28,
|
||||||
color: AppColors.textSecondary,
|
fontWeight: '700',
|
||||||
marginTop: Spacing.xs,
|
color: '#000000',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
tagline: {
|
||||||
|
fontSize: 15,
|
||||||
|
color: '#8E8E93',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#C7C7CC',
|
||||||
|
marginTop: 8,
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
marginTop: Spacing.md,
|
marginTop: 24,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: FontSizes.sm,
|
fontSize: 13,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
color: AppColors.textSecondary,
|
color: '#8E8E93',
|
||||||
paddingHorizontal: Spacing.lg,
|
|
||||||
paddingVertical: Spacing.sm,
|
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginLeft: 16,
|
||||||
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: AppColors.background,
|
backgroundColor: '#FFFFFF',
|
||||||
paddingVertical: Spacing.sm,
|
marginHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
},
|
},
|
||||||
infoRow: {
|
cardText: {
|
||||||
flexDirection: 'row',
|
fontSize: 15,
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingVertical: Spacing.sm,
|
|
||||||
paddingHorizontal: Spacing.lg,
|
|
||||||
},
|
|
||||||
infoLabel: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
},
|
|
||||||
infoValue: {
|
|
||||||
fontSize: FontSizes.base,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
},
|
|
||||||
infoDivider: {
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: AppColors.border,
|
|
||||||
marginHorizontal: Spacing.lg,
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
lineHeight: 22,
|
lineHeight: 22,
|
||||||
paddingHorizontal: Spacing.lg,
|
color: '#000000',
|
||||||
paddingVertical: Spacing.sm,
|
padding: 16,
|
||||||
},
|
},
|
||||||
featureItem: {
|
featureItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: Spacing.sm,
|
padding: 14,
|
||||||
paddingHorizontal: Spacing.lg,
|
|
||||||
},
|
},
|
||||||
featureIcon: {
|
featureIcon: {
|
||||||
width: 40,
|
width: 36,
|
||||||
height: 40,
|
height: 36,
|
||||||
borderRadius: BorderRadius.md,
|
borderRadius: 8,
|
||||||
justifyContent: 'center',
|
backgroundColor: 'rgba(10, 132, 255, 0.1)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
featureContent: {
|
featureContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginLeft: Spacing.md,
|
marginLeft: 12,
|
||||||
},
|
},
|
||||||
featureTitle: {
|
featureTitle: {
|
||||||
fontSize: FontSizes.base,
|
fontSize: 16,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
color: AppColors.textPrimary,
|
color: '#000000',
|
||||||
},
|
},
|
||||||
featureDescription: {
|
featureSubtitle: {
|
||||||
fontSize: FontSizes.xs,
|
fontSize: 13,
|
||||||
color: AppColors.textMuted,
|
color: '#8E8E93',
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
linkRow: {
|
linkItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: Spacing.md,
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: Spacing.lg,
|
padding: 14,
|
||||||
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
linkText: {
|
linkTitle: {
|
||||||
flex: 1,
|
fontSize: 16,
|
||||||
fontSize: FontSizes.base,
|
color: '#000000',
|
||||||
color: AppColors.primary,
|
|
||||||
marginLeft: Spacing.md,
|
|
||||||
},
|
},
|
||||||
linkDivider: {
|
itemBorder: {
|
||||||
height: 1,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
backgroundColor: AppColors.border,
|
borderBottomColor: '#E5E5EA',
|
||||||
marginLeft: Spacing.lg + 20 + Spacing.md,
|
|
||||||
},
|
},
|
||||||
footer: {
|
footer: {
|
||||||
alignItems: 'center',
|
textAlign: 'center',
|
||||||
paddingVertical: Spacing.xl,
|
fontSize: 13,
|
||||||
paddingBottom: Spacing.xxl,
|
color: '#8E8E93',
|
||||||
},
|
marginTop: 32,
|
||||||
copyright: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
fontSize: FontSizes.xs,
|
|
||||||
color: AppColors.textMuted,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
madeWith: {
|
|
||||||
fontSize: FontSizes.xs,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
marginTop: Spacing.md,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,61 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
|
const Stripe = require('stripe');
|
||||||
const { supabase } = require('../config/supabase');
|
const { supabase } = require('../config/supabase');
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get subscription status from Stripe (source of truth)
|
||||||
|
*/
|
||||||
|
async function getStripeSubscriptionStatus(stripeCustomerId) {
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
return { plan: 'free', status: 'none', hasSubscription: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get active subscriptions from Stripe
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
status: 'active',
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptions.data.length > 0) {
|
||||||
|
const sub = subscriptions.data[0];
|
||||||
|
return {
|
||||||
|
plan: 'premium',
|
||||||
|
status: 'active',
|
||||||
|
hasSubscription: true,
|
||||||
|
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
|
||||||
|
cancelAtPeriodEnd: sub.cancel_at_period_end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for past_due or other statuses
|
||||||
|
const allSubs = await stripe.subscriptions.list({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allSubs.data.length > 0) {
|
||||||
|
const sub = allSubs.data[0];
|
||||||
|
return {
|
||||||
|
plan: sub.status === 'canceled' ? 'free' : 'premium',
|
||||||
|
status: sub.status,
|
||||||
|
hasSubscription: false,
|
||||||
|
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { plan: 'free', status: 'none', hasSubscription: false };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching Stripe subscription:', error);
|
||||||
|
return { plan: 'free', status: 'none', hasSubscription: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to verify JWT token
|
* Middleware to verify JWT token
|
||||||
*/
|
*/
|
||||||
@ -118,13 +171,8 @@ router.get('/:id', async (req, res) => {
|
|||||||
return res.status(404).json({ error: 'Beneficiary not found' });
|
return res.status(404).json({ error: 'Beneficiary not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get subscription for this beneficiary
|
// Get subscription status from Stripe (source of truth)
|
||||||
const { data: subscription } = await supabase
|
const subscription = await getStripeSubscriptionStatus(beneficiary.stripe_customer_id);
|
||||||
.from('subscriptions')
|
|
||||||
.select('*')
|
|
||||||
.eq('beneficiary_id', beneficiaryId)
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// Get orders for this beneficiary
|
// Get orders for this beneficiary
|
||||||
const { data: orders } = await supabase
|
const { data: orders } = await supabase
|
||||||
@ -148,11 +196,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
country: beneficiary.address_country
|
country: beneficiary.address_country
|
||||||
},
|
},
|
||||||
role: access.role,
|
role: access.role,
|
||||||
subscription: subscription ? {
|
subscription: subscription,
|
||||||
plan: subscription.plan,
|
|
||||||
status: subscription.status,
|
|
||||||
currentPeriodEnd: subscription.current_period_end
|
|
||||||
} : { plan: 'free', status: 'active' },
|
|
||||||
orders: orders || []
|
orders: orders || []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -204,6 +204,369 @@ router.get('/products', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get or create Stripe Customer for a beneficiary
|
||||||
|
* Customer is tied to BENEFICIARY (not user) so subscription persists when access is transferred
|
||||||
|
*/
|
||||||
|
async function getOrCreateStripeCustomer(beneficiaryId) {
|
||||||
|
// Get beneficiary from DB
|
||||||
|
const { data: beneficiary, error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('id, email, first_name, last_name, stripe_customer_id')
|
||||||
|
.eq('id', beneficiaryId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !beneficiary) {
|
||||||
|
throw new Error('Beneficiary not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already has Stripe customer, return it
|
||||||
|
if (beneficiary.stripe_customer_id) {
|
||||||
|
return beneficiary.stripe_customer_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new Stripe customer for this beneficiary
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email: beneficiary.email,
|
||||||
|
name: `${beneficiary.first_name || ''} ${beneficiary.last_name || ''}`.trim() || undefined,
|
||||||
|
metadata: {
|
||||||
|
beneficiary_id: beneficiary.id.toString(),
|
||||||
|
type: 'beneficiary'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save stripe_customer_id to DB
|
||||||
|
await supabase
|
||||||
|
.from('users')
|
||||||
|
.update({ stripe_customer_id: customer.id })
|
||||||
|
.eq('id', beneficiaryId);
|
||||||
|
|
||||||
|
console.log(`✓ Created Stripe customer ${customer.id} for beneficiary ${beneficiaryId}`);
|
||||||
|
return customer.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/create-subscription
|
||||||
|
* Creates a Stripe Subscription for a beneficiary
|
||||||
|
* Uses Stripe as the source of truth - no local subscription table needed!
|
||||||
|
*/
|
||||||
|
router.post('/create-subscription', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { beneficiaryId, paymentMethodId } = req.body;
|
||||||
|
|
||||||
|
if (!beneficiaryId) {
|
||||||
|
return res.status(400).json({ error: 'beneficiaryId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create Stripe customer for this beneficiary
|
||||||
|
const customerId = await getOrCreateStripeCustomer(beneficiaryId);
|
||||||
|
|
||||||
|
// Attach payment method to customer if provided
|
||||||
|
if (paymentMethodId) {
|
||||||
|
await stripe.paymentMethods.attach(paymentMethodId, { customer: customerId });
|
||||||
|
await stripe.customers.update(customerId, {
|
||||||
|
invoice_settings: { default_payment_method: paymentMethodId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if customer already has an active subscription
|
||||||
|
const existingSubs = await stripe.subscriptions.list({
|
||||||
|
customer: customerId,
|
||||||
|
status: 'active',
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSubs.data.length > 0) {
|
||||||
|
const sub = existingSubs.data[0];
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
subscription: {
|
||||||
|
id: sub.id,
|
||||||
|
status: sub.status,
|
||||||
|
plan: 'premium',
|
||||||
|
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString(),
|
||||||
|
cancelAtPeriodEnd: sub.cancel_at_period_end
|
||||||
|
},
|
||||||
|
message: 'Subscription already active'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new subscription in Stripe
|
||||||
|
const subscription = await stripe.subscriptions.create({
|
||||||
|
customer: customerId,
|
||||||
|
items: [{ price: process.env.STRIPE_PRICE_PREMIUM }],
|
||||||
|
payment_behavior: 'default_incomplete',
|
||||||
|
payment_settings: { save_default_payment_method: 'on_subscription' },
|
||||||
|
expand: ['latest_invoice.payment_intent'],
|
||||||
|
metadata: {
|
||||||
|
beneficiary_id: beneficiaryId.toString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Created Stripe subscription ${subscription.id} for beneficiary ${beneficiaryId}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
subscription: {
|
||||||
|
id: subscription.id,
|
||||||
|
status: subscription.status,
|
||||||
|
plan: 'premium',
|
||||||
|
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString()
|
||||||
|
},
|
||||||
|
clientSecret: subscription.latest_invoice?.payment_intent?.client_secret
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create subscription error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/stripe/subscription-status/:beneficiaryId
|
||||||
|
* Gets subscription status directly from Stripe (source of truth)
|
||||||
|
*/
|
||||||
|
router.get('/subscription-status/:beneficiaryId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { beneficiaryId } = req.params;
|
||||||
|
|
||||||
|
// Get beneficiary's stripe_customer_id
|
||||||
|
const { data: beneficiary } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('stripe_customer_id')
|
||||||
|
.eq('id', beneficiaryId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!beneficiary?.stripe_customer_id) {
|
||||||
|
return res.json({
|
||||||
|
status: 'none',
|
||||||
|
plan: 'free',
|
||||||
|
hasSubscription: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active subscriptions from Stripe
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: beneficiary.stripe_customer_id,
|
||||||
|
status: 'active',
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptions.data.length === 0) {
|
||||||
|
// Check for past_due or canceled
|
||||||
|
const allSubs = await stripe.subscriptions.list({
|
||||||
|
customer: beneficiary.stripe_customer_id,
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allSubs.data.length > 0) {
|
||||||
|
const sub = allSubs.data[0];
|
||||||
|
return res.json({
|
||||||
|
status: sub.status,
|
||||||
|
plan: sub.status === 'canceled' ? 'free' : 'premium',
|
||||||
|
hasSubscription: false,
|
||||||
|
subscriptionId: sub.id,
|
||||||
|
currentPeriodEnd: new Date(sub.current_period_end * 1000).toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
status: 'none',
|
||||||
|
plan: 'free',
|
||||||
|
hasSubscription: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = subscriptions.data[0];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'active',
|
||||||
|
plan: 'premium',
|
||||||
|
hasSubscription: true,
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||||
|
cancelAtPeriodEnd: subscription.cancel_at_period_end
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get subscription status error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/cancel-subscription
|
||||||
|
* Cancels subscription at period end
|
||||||
|
*/
|
||||||
|
router.post('/cancel-subscription', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { beneficiaryId } = req.body;
|
||||||
|
|
||||||
|
if (!beneficiaryId) {
|
||||||
|
return res.status(400).json({ error: 'beneficiaryId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get beneficiary's stripe_customer_id
|
||||||
|
const { data: beneficiary } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('stripe_customer_id')
|
||||||
|
.eq('id', beneficiaryId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!beneficiary?.stripe_customer_id) {
|
||||||
|
return res.status(404).json({ error: 'No subscription found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get active subscription
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: beneficiary.stripe_customer_id,
|
||||||
|
status: 'active',
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptions.data.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'No active subscription found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel at period end (not immediately)
|
||||||
|
const subscription = await stripe.subscriptions.update(subscriptions.data[0].id, {
|
||||||
|
cancel_at_period_end: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Subscription ${subscription.id} will cancel at period end`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Subscription will cancel at the end of the billing period',
|
||||||
|
cancelAt: new Date(subscription.current_period_end * 1000).toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cancel subscription error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/reactivate-subscription
|
||||||
|
* Reactivates a subscription that was set to cancel
|
||||||
|
*/
|
||||||
|
router.post('/reactivate-subscription', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { beneficiaryId } = req.body;
|
||||||
|
|
||||||
|
if (!beneficiaryId) {
|
||||||
|
return res.status(400).json({ error: 'beneficiaryId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: beneficiary } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('stripe_customer_id')
|
||||||
|
.eq('id', beneficiaryId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!beneficiary?.stripe_customer_id) {
|
||||||
|
return res.status(404).json({ error: 'No subscription found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: beneficiary.stripe_customer_id,
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptions.data.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'No subscription found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await stripe.subscriptions.update(subscriptions.data[0].id, {
|
||||||
|
cancel_at_period_end: false
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✓ Subscription ${subscription.id} reactivated`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Subscription reactivated',
|
||||||
|
status: subscription.status
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reactivate subscription error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/stripe/create-subscription-payment-sheet
|
||||||
|
* Creates a SetupIntent for collecting payment method in React Native app
|
||||||
|
* Then creates subscription with that payment method
|
||||||
|
*/
|
||||||
|
router.post('/create-subscription-payment-sheet', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { beneficiaryId } = req.body;
|
||||||
|
|
||||||
|
if (!beneficiaryId) {
|
||||||
|
return res.status(400).json({ error: 'beneficiaryId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create Stripe customer for this beneficiary
|
||||||
|
const customerId = await getOrCreateStripeCustomer(beneficiaryId);
|
||||||
|
|
||||||
|
// Check if already has active subscription
|
||||||
|
const existingSubs = await stripe.subscriptions.list({
|
||||||
|
customer: customerId,
|
||||||
|
status: 'active',
|
||||||
|
limit: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingSubs.data.length > 0) {
|
||||||
|
return res.json({
|
||||||
|
alreadySubscribed: true,
|
||||||
|
subscription: {
|
||||||
|
id: existingSubs.data[0].id,
|
||||||
|
status: 'active',
|
||||||
|
currentPeriodEnd: new Date(existingSubs.data[0].current_period_end * 1000).toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create ephemeral key
|
||||||
|
const ephemeralKey = await stripe.ephemeralKeys.create(
|
||||||
|
{ customer: customerId },
|
||||||
|
{ apiVersion: '2024-12-18.acacia' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create subscription with incomplete status (needs payment)
|
||||||
|
const subscription = await stripe.subscriptions.create({
|
||||||
|
customer: customerId,
|
||||||
|
items: [{ price: process.env.STRIPE_PRICE_PREMIUM }],
|
||||||
|
payment_behavior: 'default_incomplete',
|
||||||
|
payment_settings: {
|
||||||
|
save_default_payment_method: 'on_subscription',
|
||||||
|
payment_method_types: ['card']
|
||||||
|
},
|
||||||
|
expand: ['latest_invoice.payment_intent'],
|
||||||
|
metadata: {
|
||||||
|
beneficiary_id: beneficiaryId.toString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentIntent = subscription.latest_invoice?.payment_intent;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
clientSecret: paymentIntent?.client_secret,
|
||||||
|
ephemeralKey: ephemeralKey.secret,
|
||||||
|
customer: customerId,
|
||||||
|
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create subscription payment sheet error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/stripe/session/:sessionId
|
* GET /api/stripe/session/:sessionId
|
||||||
* Get checkout session details (for success page)
|
* Get checkout session details (for success page)
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { usePaymentSheet } from '@stripe/stripe-react-native';
|
import { usePaymentSheet } from '@stripe/stripe-react-native';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
|
import { AppColors, BorderRadius, FontSizes, FontWeights, Spacing, Shadows } from '@/constants/theme';
|
||||||
@ -26,7 +25,6 @@ interface SubscriptionPaymentProps {
|
|||||||
export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }: SubscriptionPaymentProps) {
|
export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }: SubscriptionPaymentProps) {
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
|
const { initPaymentSheet, presentPaymentSheet } = usePaymentSheet();
|
||||||
const { user } = useAuth();
|
|
||||||
const { updateLocalBeneficiary } = useBeneficiary();
|
const { updateLocalBeneficiary } = useBeneficiary();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@ -36,40 +34,50 @@ export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }:
|
|||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create Payment Sheet on our server
|
// 1. Create subscription payment sheet via new Stripe Subscriptions API
|
||||||
const response = await fetch(`${STRIPE_API_URL}/create-payment-sheet`, {
|
const response = await fetch(`${STRIPE_API_URL}/create-subscription-payment-sheet`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: user?.email || 'guest@wellnuo.com',
|
|
||||||
amount: SUBSCRIPTION_PRICE * 100, // Convert to cents ($49.00)
|
|
||||||
metadata: {
|
|
||||||
type: 'subscription',
|
|
||||||
planType: 'monthly',
|
|
||||||
userId: user?.user_id || 'guest',
|
|
||||||
beneficiaryId: beneficiary.id,
|
beneficiaryId: beneficiary.id,
|
||||||
beneficiaryName: beneficiary.name,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!data.paymentIntent) {
|
// Check if already subscribed
|
||||||
throw new Error(data.error || 'Failed to create payment sheet');
|
if (data.alreadySubscribed) {
|
||||||
|
toast.success(
|
||||||
|
'Already Subscribed!',
|
||||||
|
`${beneficiary.name} already has an active subscription.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
await updateLocalBeneficiary(beneficiary.id, {
|
||||||
|
subscription: {
|
||||||
|
status: 'active',
|
||||||
|
endDate: data.subscription.currentPeriodEnd,
|
||||||
|
planType: 'monthly',
|
||||||
|
price: SUBSCRIPTION_PRICE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onSuccess?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.clientSecret) {
|
||||||
|
throw new Error(data.error || 'Failed to create subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Initialize the Payment Sheet
|
// 2. Initialize the Payment Sheet
|
||||||
const { error: initError } = await initPaymentSheet({
|
const { error: initError } = await initPaymentSheet({
|
||||||
merchantDisplayName: 'WellNuo',
|
merchantDisplayName: 'WellNuo',
|
||||||
paymentIntentClientSecret: data.paymentIntent,
|
paymentIntentClientSecret: data.clientSecret,
|
||||||
customerId: data.customer,
|
customerId: data.customer,
|
||||||
customerEphemeralKeySecret: data.ephemeralKey,
|
customerEphemeralKeySecret: data.ephemeralKey,
|
||||||
defaultBillingDetails: {
|
|
||||||
email: user?.email || '',
|
|
||||||
},
|
|
||||||
returnURL: 'wellnuo://stripe-redirect',
|
returnURL: 'wellnuo://stripe-redirect',
|
||||||
applePay: {
|
applePay: {
|
||||||
merchantCountryCode: 'US',
|
merchantCountryCode: 'US',
|
||||||
@ -95,15 +103,17 @@ export function SubscriptionPayment({ beneficiary, onSuccess, compact = false }:
|
|||||||
throw new Error(presentError.message);
|
throw new Error(presentError.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Payment successful! Save subscription to beneficiary
|
// 4. Payment successful! Subscription is now active in Stripe
|
||||||
const now = new Date();
|
// Fetch the current status from Stripe to confirm
|
||||||
const endDate = new Date(now);
|
const statusResponse = await fetch(
|
||||||
endDate.setMonth(endDate.getMonth() + 1); // 1 month subscription
|
`${STRIPE_API_URL}/subscription-status/${beneficiary.id}`
|
||||||
|
);
|
||||||
|
const statusData = await statusResponse.json();
|
||||||
|
|
||||||
|
// Update local state with data from Stripe
|
||||||
const newSubscription: BeneficiarySubscription = {
|
const newSubscription: BeneficiarySubscription = {
|
||||||
status: 'active',
|
status: statusData.status === 'active' ? 'active' : 'none',
|
||||||
startDate: now.toISOString(),
|
endDate: statusData.currentPeriodEnd,
|
||||||
endDate: endDate.toISOString(),
|
|
||||||
planType: 'monthly',
|
planType: 'monthly',
|
||||||
price: SUBSCRIPTION_PRICE,
|
price: SUBSCRIPTION_PRICE,
|
||||||
};
|
};
|
||||||
|
|||||||
179
hooks/useNavigationFlow.ts
Normal file
179
hooks/useNavigationFlow.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* useNavigationFlow - React hook for centralized navigation
|
||||||
|
*
|
||||||
|
* This hook wraps NavigationController and provides easy-to-use
|
||||||
|
* navigation methods for components.
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* const nav = useNavigationFlow();
|
||||||
|
* nav.navigateAfterLogin(profile, beneficiaries);
|
||||||
|
* nav.navigateAfterAddBeneficiary(42, false);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { NavigationController, ROUTES, type NavigationResult, type UserProfile } from '@/services/NavigationController';
|
||||||
|
import type { Beneficiary } from '@/types';
|
||||||
|
|
||||||
|
export function useNavigationFlow() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute navigation based on NavigationResult
|
||||||
|
*/
|
||||||
|
const navigate = useCallback((result: NavigationResult, replace = false) => {
|
||||||
|
const href = result.params
|
||||||
|
? { pathname: result.path, params: result.params }
|
||||||
|
: result.path;
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
router.replace(href as any);
|
||||||
|
} else {
|
||||||
|
router.push(href as any);
|
||||||
|
}
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate after successful login/OTP verification
|
||||||
|
*/
|
||||||
|
const navigateAfterLogin = useCallback((
|
||||||
|
profile: UserProfile | null,
|
||||||
|
beneficiaries: Beneficiary[]
|
||||||
|
) => {
|
||||||
|
const result = NavigationController.getRouteAfterLogin(profile, beneficiaries);
|
||||||
|
navigate(result, true); // replace to prevent going back to login
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate after creating a new beneficiary
|
||||||
|
*/
|
||||||
|
const navigateAfterAddBeneficiary = useCallback((
|
||||||
|
beneficiaryId: number,
|
||||||
|
hasExistingDevices: boolean
|
||||||
|
) => {
|
||||||
|
const result = NavigationController.getRouteAfterAddBeneficiary(
|
||||||
|
beneficiaryId,
|
||||||
|
hasExistingDevices
|
||||||
|
);
|
||||||
|
navigate(result);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate after purchase completion
|
||||||
|
*/
|
||||||
|
const navigateAfterPurchase = useCallback((
|
||||||
|
beneficiaryId: number,
|
||||||
|
options: { skipToActivate?: boolean; demo?: boolean } = {}
|
||||||
|
) => {
|
||||||
|
const result = NavigationController.getRouteAfterPurchase(beneficiaryId, options);
|
||||||
|
navigate(result);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate after device activation
|
||||||
|
*/
|
||||||
|
const navigateAfterActivation = useCallback((beneficiaryId: number) => {
|
||||||
|
const result = NavigationController.getRouteAfterActivation(beneficiaryId);
|
||||||
|
navigate(result, true); // replace to prevent going back
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to beneficiary-specific screens
|
||||||
|
*/
|
||||||
|
const navigateToBeneficiary = useCallback((
|
||||||
|
beneficiary: Beneficiary,
|
||||||
|
action: 'view' | 'subscription' | 'equipment' | 'share' = 'view'
|
||||||
|
) => {
|
||||||
|
const result = NavigationController.getBeneficiaryRoute(beneficiary, action);
|
||||||
|
navigate(result);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to beneficiary setup flow (purchase or activate)
|
||||||
|
*/
|
||||||
|
const navigateToBeneficiarySetup = useCallback((beneficiary: Beneficiary) => {
|
||||||
|
const result = NavigationController.getRouteForBeneficiarySetup(beneficiary);
|
||||||
|
navigate(result);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to specific route
|
||||||
|
*/
|
||||||
|
const goTo = useCallback((
|
||||||
|
path: string,
|
||||||
|
params?: Record<string, string | number | boolean>,
|
||||||
|
replace = false
|
||||||
|
) => {
|
||||||
|
navigate({ path, params }, replace);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick navigation shortcuts
|
||||||
|
*/
|
||||||
|
const goToDashboard = useCallback(() => {
|
||||||
|
navigate({ path: ROUTES.TABS.DASHBOARD }, true);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const goToLogin = useCallback(() => {
|
||||||
|
navigate({ path: ROUTES.AUTH.LOGIN }, true);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const goToAddBeneficiary = useCallback(() => {
|
||||||
|
navigate({ path: ROUTES.AUTH.ADD_LOVED_ONE });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const goToPurchase = useCallback((beneficiaryId: number) => {
|
||||||
|
navigate({
|
||||||
|
path: ROUTES.AUTH.PURCHASE,
|
||||||
|
params: { beneficiaryId },
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const goToActivate = useCallback((beneficiaryId: number, demo?: boolean) => {
|
||||||
|
navigate({
|
||||||
|
path: ROUTES.AUTH.ACTIVATE,
|
||||||
|
params: { beneficiaryId, demo },
|
||||||
|
});
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const goToProfile = useCallback(() => {
|
||||||
|
navigate({ path: ROUTES.TABS.PROFILE });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const goToEditProfile = useCallback(() => {
|
||||||
|
navigate({ path: ROUTES.PROFILE.EDIT });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Core navigation methods
|
||||||
|
navigate,
|
||||||
|
goTo,
|
||||||
|
|
||||||
|
// Flow-based navigation
|
||||||
|
navigateAfterLogin,
|
||||||
|
navigateAfterAddBeneficiary,
|
||||||
|
navigateAfterPurchase,
|
||||||
|
navigateAfterActivation,
|
||||||
|
|
||||||
|
// Beneficiary navigation
|
||||||
|
navigateToBeneficiary,
|
||||||
|
navigateToBeneficiarySetup,
|
||||||
|
|
||||||
|
// Quick shortcuts
|
||||||
|
goToDashboard,
|
||||||
|
goToLogin,
|
||||||
|
goToAddBeneficiary,
|
||||||
|
goToPurchase,
|
||||||
|
goToActivate,
|
||||||
|
goToProfile,
|
||||||
|
goToEditProfile,
|
||||||
|
|
||||||
|
// Direct access to routes
|
||||||
|
ROUTES,
|
||||||
|
|
||||||
|
// Direct access to controller for advanced use
|
||||||
|
controller: NavigationController,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useNavigationFlow;
|
||||||
375
services/NavigationController.ts
Normal file
375
services/NavigationController.ts
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* NavigationController - Centralized navigation logic for WellNuo app
|
||||||
|
*
|
||||||
|
* This service provides all routing decisions based on user state,
|
||||||
|
* beneficiary data, and equipment status.
|
||||||
|
*
|
||||||
|
* USAGE:
|
||||||
|
* import { NavigationController } from '@/services/NavigationController';
|
||||||
|
* const { path, params } = NavigationController.getRouteAfterLogin(profile, beneficiaries);
|
||||||
|
* router.replace(path);
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Beneficiary, EquipmentStatus } from '@/types';
|
||||||
|
|
||||||
|
// ==================== ROUTE CONSTANTS ====================
|
||||||
|
|
||||||
|
export const ROUTES = {
|
||||||
|
// Auth Flow
|
||||||
|
AUTH: {
|
||||||
|
LOGIN: '/(auth)/login',
|
||||||
|
VERIFY_OTP: '/(auth)/verify-otp',
|
||||||
|
ENTER_NAME: '/(auth)/enter-name',
|
||||||
|
ADD_LOVED_ONE: '/(auth)/add-loved-one',
|
||||||
|
PURCHASE: '/(auth)/purchase',
|
||||||
|
ACTIVATE: '/(auth)/activate',
|
||||||
|
WELCOME_BACK: '/(auth)/welcome-back',
|
||||||
|
COMPLETE_PROFILE: '/(auth)/complete-profile',
|
||||||
|
},
|
||||||
|
// Main Tabs
|
||||||
|
TABS: {
|
||||||
|
DASHBOARD: '/(tabs)/dashboard',
|
||||||
|
BENEFICIARIES: '/(tabs)/beneficiaries',
|
||||||
|
CHAT: '/(tabs)/chat',
|
||||||
|
VOICE: '/(tabs)/voice',
|
||||||
|
PROFILE: '/(tabs)/profile',
|
||||||
|
},
|
||||||
|
// Beneficiary Management
|
||||||
|
BENEFICIARY: {
|
||||||
|
ADD: '/(tabs)/beneficiaries/add',
|
||||||
|
DETAIL: (id: number) => `/(tabs)/beneficiaries/${id}` as const,
|
||||||
|
SUBSCRIPTION: (id: number) => `/(tabs)/beneficiaries/${id}/subscription` as const,
|
||||||
|
EQUIPMENT: (id: number) => `/(tabs)/beneficiaries/${id}/equipment` as const,
|
||||||
|
SHARE: (id: number) => `/(tabs)/beneficiaries/${id}/share` as const,
|
||||||
|
},
|
||||||
|
// Profile
|
||||||
|
PROFILE: {
|
||||||
|
INDEX: '/(tabs)/profile',
|
||||||
|
EDIT: '/(tabs)/profile/edit',
|
||||||
|
NOTIFICATIONS: '/(tabs)/profile/notifications',
|
||||||
|
LANGUAGE: '/(tabs)/profile/language',
|
||||||
|
PRIVACY: '/(tabs)/profile/privacy',
|
||||||
|
TERMS: '/(tabs)/profile/terms',
|
||||||
|
SUPPORT: '/(tabs)/profile/support',
|
||||||
|
ABOUT: '/(tabs)/profile/about',
|
||||||
|
HELP: '/(tabs)/profile/help',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ==================== TYPE DEFINITIONS ====================
|
||||||
|
|
||||||
|
export interface NavigationResult {
|
||||||
|
path: string;
|
||||||
|
params?: Record<string, string | number | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NAVIGATION CONTROLLER ====================
|
||||||
|
|
||||||
|
export const NavigationController = {
|
||||||
|
/**
|
||||||
|
* Determine where to navigate after successful OTP verification
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. New user without name → enter-name
|
||||||
|
* 2. User without beneficiaries → add-loved-one
|
||||||
|
* 3. User has beneficiary without devices → check hasDevices
|
||||||
|
* - hasDevices=false → purchase (need to buy equipment)
|
||||||
|
* - hasDevices=true but equipment not activated → activate
|
||||||
|
* 4. User has active beneficiary → dashboard
|
||||||
|
*/
|
||||||
|
getRouteAfterLogin(
|
||||||
|
profile: UserProfile | null,
|
||||||
|
beneficiaries: Beneficiary[]
|
||||||
|
): NavigationResult {
|
||||||
|
// Step 1: Check if user has name
|
||||||
|
if (!profile?.firstName) {
|
||||||
|
return {
|
||||||
|
path: ROUTES.AUTH.ENTER_NAME,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check if user has any beneficiaries
|
||||||
|
if (!beneficiaries || beneficiaries.length === 0) {
|
||||||
|
return {
|
||||||
|
path: ROUTES.AUTH.ADD_LOVED_ONE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check if any beneficiary needs equipment setup
|
||||||
|
const beneficiaryNeedingSetup = beneficiaries.find(b =>
|
||||||
|
!b.hasDevices && b.equipmentStatus !== 'active' && b.equipmentStatus !== 'demo'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (beneficiaryNeedingSetup) {
|
||||||
|
return this.getRouteForBeneficiarySetup(beneficiaryNeedingSetup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: All set, go to dashboard
|
||||||
|
return {
|
||||||
|
path: ROUTES.TABS.DASHBOARD,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine where to navigate for beneficiary equipment setup
|
||||||
|
*/
|
||||||
|
getRouteForBeneficiarySetup(beneficiary: Beneficiary): NavigationResult {
|
||||||
|
const status = beneficiary.equipmentStatus || 'none';
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'none':
|
||||||
|
// No equipment ordered - go to purchase
|
||||||
|
return {
|
||||||
|
path: ROUTES.AUTH.PURCHASE,
|
||||||
|
params: { beneficiaryId: beneficiary.id },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'ordered':
|
||||||
|
case 'shipped':
|
||||||
|
// Equipment is on the way - can show tracking or wait screen
|
||||||
|
// For now, go to equipment status page
|
||||||
|
return {
|
||||||
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
|
||||||
|
params: { beneficiaryId: beneficiary.id },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'delivered':
|
||||||
|
// Equipment delivered, needs activation
|
||||||
|
return {
|
||||||
|
path: ROUTES.AUTH.ACTIVATE,
|
||||||
|
params: { beneficiaryId: beneficiary.id },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'active':
|
||||||
|
case 'demo':
|
||||||
|
// Already active, go to dashboard
|
||||||
|
return {
|
||||||
|
path: ROUTES.TABS.DASHBOARD,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown status - go to dashboard
|
||||||
|
return {
|
||||||
|
path: ROUTES.TABS.DASHBOARD,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After creating a new beneficiary, determine next step
|
||||||
|
*
|
||||||
|
* @param beneficiaryId - ID of newly created beneficiary
|
||||||
|
* @param hasExistingDevices - User indicated they already have WellNuo devices
|
||||||
|
*/
|
||||||
|
getRouteAfterAddBeneficiary(
|
||||||
|
beneficiaryId: number,
|
||||||
|
hasExistingDevices: boolean
|
||||||
|
): NavigationResult {
|
||||||
|
if (hasExistingDevices) {
|
||||||
|
// User has existing devices - go directly to activation
|
||||||
|
return {
|
||||||
|
path: ROUTES.AUTH.ACTIVATE,
|
||||||
|
params: { beneficiaryId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// User needs to purchase equipment
|
||||||
|
return {
|
||||||
|
path: ROUTES.AUTH.PURCHASE,
|
||||||
|
params: { beneficiaryId },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After successful purchase, navigate to next step
|
||||||
|
*/
|
||||||
|
getRouteAfterPurchase(
|
||||||
|
beneficiaryId: number,
|
||||||
|
purchaseResult: { skipToActivate?: boolean; demo?: boolean }
|
||||||
|
): NavigationResult {
|
||||||
|
if (purchaseResult.demo || purchaseResult.skipToActivate) {
|
||||||
|
// Demo mode or skip - go to activate
|
||||||
|
return {
|
||||||
|
path: ROUTES.AUTH.ACTIVATE,
|
||||||
|
params: { beneficiaryId, demo: purchaseResult.demo },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal purchase - wait for equipment delivery
|
||||||
|
// Go to equipment tracking page
|
||||||
|
return {
|
||||||
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiaryId),
|
||||||
|
params: { beneficiaryId },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After successful activation, navigate to beneficiary detail page
|
||||||
|
*/
|
||||||
|
getRouteAfterActivation(beneficiaryId: number): NavigationResult {
|
||||||
|
return {
|
||||||
|
path: ROUTES.BENEFICIARY.DETAIL(beneficiaryId),
|
||||||
|
params: { justActivated: true },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get route for returning user (already has account)
|
||||||
|
*
|
||||||
|
* Used when user logs in with existing account
|
||||||
|
*/
|
||||||
|
getRouteForReturningUser(
|
||||||
|
profile: UserProfile,
|
||||||
|
beneficiaries: Beneficiary[]
|
||||||
|
): NavigationResult {
|
||||||
|
// Same logic as after login, but could add welcome-back screen
|
||||||
|
return this.getRouteAfterLogin(profile, beneficiaries);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user should see onboarding
|
||||||
|
*/
|
||||||
|
shouldShowOnboarding(
|
||||||
|
isNewUser: boolean,
|
||||||
|
profile: UserProfile | null
|
||||||
|
): boolean {
|
||||||
|
return isNewUser || !profile?.firstName;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get route for a specific beneficiary action
|
||||||
|
*/
|
||||||
|
getBeneficiaryRoute(
|
||||||
|
beneficiary: Beneficiary,
|
||||||
|
action: 'view' | 'subscription' | 'equipment' | 'share'
|
||||||
|
): NavigationResult {
|
||||||
|
switch (action) {
|
||||||
|
case 'view':
|
||||||
|
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
|
||||||
|
case 'subscription':
|
||||||
|
return { path: ROUTES.BENEFICIARY.SUBSCRIPTION(beneficiary.id) };
|
||||||
|
case 'equipment':
|
||||||
|
return { path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id) };
|
||||||
|
case 'share':
|
||||||
|
return { path: ROUTES.BENEFICIARY.SHARE(beneficiary.id) };
|
||||||
|
default:
|
||||||
|
return { path: ROUTES.BENEFICIARY.DETAIL(beneficiary.id) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if beneficiary card should show "Setup Equipment" button
|
||||||
|
*/
|
||||||
|
shouldShowEquipmentSetup(beneficiary: Beneficiary): boolean {
|
||||||
|
return !beneficiary.hasDevices &&
|
||||||
|
beneficiary.equipmentStatus !== 'active' &&
|
||||||
|
beneficiary.equipmentStatus !== 'demo';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get call-to-action for beneficiary based on status
|
||||||
|
*/
|
||||||
|
getBeneficiaryCallToAction(
|
||||||
|
beneficiary: Beneficiary
|
||||||
|
): { label: string; action: () => NavigationResult } | null {
|
||||||
|
const status = beneficiary.equipmentStatus || 'none';
|
||||||
|
|
||||||
|
if (beneficiary.hasDevices || status === 'active' || status === 'demo') {
|
||||||
|
// No CTA needed - beneficiary is active
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'none':
|
||||||
|
return {
|
||||||
|
label: 'Setup Equipment',
|
||||||
|
action: () => ({
|
||||||
|
path: ROUTES.AUTH.PURCHASE,
|
||||||
|
params: { beneficiaryId: beneficiary.id },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'ordered':
|
||||||
|
return {
|
||||||
|
label: 'Track Delivery',
|
||||||
|
action: () => ({
|
||||||
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'shipped':
|
||||||
|
return {
|
||||||
|
label: 'Track Package',
|
||||||
|
action: () => ({
|
||||||
|
path: ROUTES.BENEFICIARY.EQUIPMENT(beneficiary.id),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'delivered':
|
||||||
|
return {
|
||||||
|
label: 'Activate Equipment',
|
||||||
|
action: () => ({
|
||||||
|
path: ROUTES.AUTH.ACTIVATE,
|
||||||
|
params: { beneficiaryId: beneficiary.id },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status text for equipment
|
||||||
|
*/
|
||||||
|
getEquipmentStatusText(status: EquipmentStatus | undefined): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'none':
|
||||||
|
return 'No equipment';
|
||||||
|
case 'ordered':
|
||||||
|
return 'Equipment ordered';
|
||||||
|
case 'shipped':
|
||||||
|
return 'In transit';
|
||||||
|
case 'delivered':
|
||||||
|
return 'Ready to activate';
|
||||||
|
case 'active':
|
||||||
|
return 'Active';
|
||||||
|
case 'demo':
|
||||||
|
return 'Demo mode';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status color for equipment
|
||||||
|
*/
|
||||||
|
getEquipmentStatusColor(status: EquipmentStatus | undefined): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'none':
|
||||||
|
return '#888888'; // gray
|
||||||
|
case 'ordered':
|
||||||
|
return '#FFA500'; // orange
|
||||||
|
case 'shipped':
|
||||||
|
return '#007AFF'; // blue
|
||||||
|
case 'delivered':
|
||||||
|
return '#34C759'; // green
|
||||||
|
case 'active':
|
||||||
|
return '#34C759'; // green
|
||||||
|
case 'demo':
|
||||||
|
return '#9C27B0'; // purple
|
||||||
|
default:
|
||||||
|
return '#888888'; // gray
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NavigationController;
|
||||||
Loading…
x
Reference in New Issue
Block a user