From f6a2d5e687e551cbe11a09f0843060a964dca1dd Mon Sep 17 00:00:00 2001 From: Sergei Date: Thu, 1 Jan 2026 13:41:34 -0800 Subject: [PATCH] Replace Alert with Toast for invite code copy, rename Share to Access --- app/(tabs)/beneficiaries/[id]/index.tsx | 136 +++++++-------------- app/(tabs)/beneficiaries/[id]/share.tsx | 153 ++++++++++++++++++++++-- app/(tabs)/index.tsx | 23 +--- app/(tabs)/profile/index.tsx | 4 +- backend/src/routes/invitations.js | 93 +++++++++++++- backend/src/services/email.js | 118 +++++++++++++++++- components/ui/DevModeToggle.tsx | 69 +++++++++++ services/api.ts | 35 ++++++ types/index.ts | 9 ++ 9 files changed, 521 insertions(+), 119 deletions(-) create mode 100644 components/ui/DevModeToggle.tsx diff --git a/app/(tabs)/beneficiaries/[id]/index.tsx b/app/(tabs)/beneficiaries/[id]/index.tsx index 57e2a0d..1b8e69c 100644 --- a/app/(tabs)/beneficiaries/[id]/index.tsx +++ b/app/(tabs)/beneficiaries/[id]/index.tsx @@ -14,10 +14,8 @@ import { Platform, Animated, ActivityIndicator, - Switch, } from 'react-native'; import { WebView } from 'react-native-webview'; -import * as SecureStore from 'expo-secure-store'; import { useLocalSearchParams, router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -31,6 +29,7 @@ import { FullScreenError } from '@/components/ui/ErrorMessage'; import { Button } from '@/components/ui/Button'; import { SubscriptionPayment } from '@/components/SubscriptionPayment'; import { useToast } from '@/components/ui/Toast'; +import { DevModeToggle } from '@/components/ui/DevModeToggle'; import MockDashboard from '@/components/MockDashboard'; import { AppColors, @@ -57,7 +56,9 @@ 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'; -const TEST_NDK_DEPLOYMENT_ID = '1'; // anandk test deployment with real sensor data +// Test credentials for WebView - anandk account has real sensor data +const TEST_NDK_USER = 'anandk'; +const TEST_NDK_PASSWORD = 'anandk_8'; // Starter Kit info const STARTER_KIT = { @@ -472,21 +473,25 @@ export default function BeneficiaryDetailScreen() { loadBeneficiary(); }, [loadBeneficiary]); - // Load credentials for WebView + // Load test credentials for WebView (anandk account has sensor data) useEffect(() => { - const loadCredentials = async () => { + const loadTestCredentials = async () => { try { - const token = await SecureStore.getItemAsync('accessToken'); - const user = await SecureStore.getItemAsync('userName'); - const uid = await SecureStore.getItemAsync('userId'); - setAuthToken(token); - setUserName(user); - setUserId(uid); + // Always use anandk test account for WebView dashboard + const response = await api.login(TEST_NDK_USER, TEST_NDK_PASSWORD); + if (response.ok && response.data) { + setAuthToken(response.data.token); + setUserName(TEST_NDK_USER); + setUserId(response.data.user_id?.toString() || null); + console.log('[WebView] Loaded test credentials for anandk'); + } else { + console.error('[WebView] Failed to get test token:', response.error); + } } catch (err) { - console.error('Failed to load credentials:', err); + console.error('[WebView] Failed to load test credentials:', err); } }; - loadCredentials(); + loadTestCredentials(); }, []); // Sync beneficiary data when localBeneficiaries changes (especially after avatar update) @@ -675,7 +680,6 @@ export default function BeneficiaryDetailScreen() { ); } - const statusColor = beneficiary.status === 'online' ? AppColors.online : AppColors.offline; // Render based on setup state const renderContent = () => { @@ -711,14 +715,19 @@ export default function BeneficiaryDetailScreen() { case 'ready': default: - // WebView mode - uses test NDK deployment for demo data + // WebView mode - uses test anandk account for all beneficiaries if (showWebView) { - const webViewUrl = `${DASHBOARD_URL}?deployment_id=${TEST_NDK_DEPLOYMENT_ID}`; + // Token is injected via injectedJavaScript, no need for URL params return ( + {/* Developer Toggle - at top */} + + + + {/* WebView below */} )} /> - {/* Developer Toggle - always visible to switch back */} - - - - - - Developer Mode - Show WebView dashboard - - - - - ); } @@ -770,20 +761,8 @@ export default function BeneficiaryDetailScreen() { } > {/* Developer Toggle for WebView */} - - - - - Developer Mode - Show WebView dashboard - - - + + {/* Activity Dashboard */} @@ -803,18 +782,15 @@ export default function BeneficiaryDetailScreen() { {/* Avatar + Name */} - - {beneficiary.avatar ? ( - - ) : ( - - - {beneficiary.name.charAt(0).toUpperCase()} - - - )} - - + {beneficiary.avatar ? ( + + ) : ( + + + {beneficiary.name.charAt(0).toUpperCase()} + + + )} {beneficiary.name} @@ -845,7 +821,7 @@ export default function BeneficiaryDetailScreen() { }} > - Share + Access 1000000000) +const isLocalBeneficiary = (id: string | number): boolean => { + const numId = typeof id === 'string' ? parseInt(id, 10) : id; + return numId > 1000000000; +}; + interface Invitation { id: string; email: string; @@ -39,8 +46,9 @@ interface Invitation { export default function ShareAccessScreen() { const { id } = useLocalSearchParams<{ id: string }>(); - const { currentBeneficiary } = useBeneficiary(); + const { currentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary(); const { user } = useAuth(); + const toast = useToast(); const [email, setEmail] = useState(''); const [role, setRole] = useState('caretaker'); @@ -52,16 +60,34 @@ export default function ShareAccessScreen() { const beneficiaryName = currentBeneficiary?.name || 'this person'; const currentUserEmail = user?.email?.toLowerCase(); + // Check if this is a local beneficiary + const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]); + + // Get local beneficiary data + const localBeneficiary = useMemo(() => { + if (!isLocal || !id) return null; + return localBeneficiaries.find(b => b.id === parseInt(id, 10)); + }, [isLocal, id, localBeneficiaries]); + // Load invitations on mount useEffect(() => { if (id) { loadInvitations(); } - }, [id]); + }, [id, isLocal, localBeneficiary]); const loadInvitations = async () => { if (!id) return; + // For local beneficiaries, use stored invitations + if (isLocal && localBeneficiary) { + setInvitations(localBeneficiary.invitations || []); + setIsLoadingInvitations(false); + setRefreshing(false); + return; + } + + // For API beneficiaries try { const response = await api.getInvitations(id); if (response.ok && response.data) { @@ -113,6 +139,35 @@ export default function ShareAccessScreen() { setIsLoading(true); + // For local beneficiaries, store invitation locally + if (isLocal && id) { + try { + const newInvitation: Invitation = { + id: `local_${Date.now()}`, + email: trimmedEmail, + role: role, + status: 'pending', + created_at: new Date().toISOString(), + }; + + const currentInvitations = localBeneficiary?.invitations || []; + await updateLocalBeneficiary(parseInt(id, 10), { + invitations: [...currentInvitations, newInvitation], + }); + + setEmail(''); + setRole('caretaker'); + setInvitations([...currentInvitations, newInvitation]); + toast.success('Invitation Sent', `Invitation sent to ${trimmedEmail}`); + } catch (error) { + toast.error('Error', 'Failed to send invitation'); + } finally { + setIsLoading(false); + } + return; + } + + // For API beneficiaries try { const response = await api.sendInvitation({ beneficiaryId: id!, @@ -124,7 +179,7 @@ export default function ShareAccessScreen() { setEmail(''); setRole('caretaker'); loadInvitations(); - Alert.alert('Success', `Invitation sent to ${trimmedEmail}`); + toast.success('Success', `Invitation sent to ${trimmedEmail}`); } else { Alert.alert('Error', response.error?.message || 'Failed to send invitation'); } @@ -146,10 +201,28 @@ export default function ShareAccessScreen() { text: 'Remove', style: 'destructive', onPress: async () => { + // For local beneficiaries + if (isLocal && id) { + try { + const currentInvitations = localBeneficiary?.invitations || []; + const updatedInvitations = currentInvitations.filter(inv => inv.id !== invitation.id); + await updateLocalBeneficiary(parseInt(id, 10), { + invitations: updatedInvitations, + }); + setInvitations(updatedInvitations); + toast.success('Removed', 'Access removed successfully'); + } catch (error) { + toast.error('Error', 'Failed to remove access'); + } + return; + } + + // For API beneficiaries try { const response = await api.deleteInvitation(invitation.id); if (response.ok) { loadInvitations(); + toast.success('Removed', 'Access removed successfully'); } else { Alert.alert('Error', 'Failed to remove access'); } @@ -167,6 +240,54 @@ export default function ShareAccessScreen() { return 'Caretaker'; }; + const handleChangeRole = (invitation: Invitation) => { + const newRole = invitation.role === 'caretaker' ? 'guardian' : 'caretaker'; + const newRoleLabel = newRole === 'guardian' ? 'Guardian' : 'Caretaker'; + + Alert.alert( + 'Change Role', + `Change ${invitation.email}'s role to ${newRoleLabel}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Change', + onPress: async () => { + // For local beneficiaries + if (isLocal && id) { + try { + const currentInvitations = localBeneficiary?.invitations || []; + const updatedInvitations = currentInvitations.map(inv => + inv.id === invitation.id ? { ...inv, role: newRole } : inv + ); + await updateLocalBeneficiary(parseInt(id, 10), { + invitations: updatedInvitations, + }); + setInvitations(updatedInvitations); + toast.success('Updated', `Role changed to ${newRoleLabel}`); + } catch (error) { + toast.error('Error', 'Failed to change role'); + } + return; + } + + // For API beneficiaries + try { + const response = await api.updateInvitation(invitation.id, newRole as 'caretaker' | 'guardian'); + if (response.ok) { + loadInvitations(); + toast.success('Updated', `Role changed to ${newRoleLabel}`); + } else { + Alert.alert('Error', response.error?.message || 'Failed to change role'); + } + } catch (error) { + Alert.alert('Error', 'Failed to change role'); + } + }, + }, + ] + ); + }; + const getStatusColor = (status: string): string => { switch (status) { case 'accepted': return AppColors.success; @@ -282,9 +403,15 @@ export default function ShareAccessScreen() { {invitation.email} - - {getRoleLabel(invitation.role)} - + handleChangeRole(invitation)} + > + + {getRoleLabel(invitation.role)} + + + {invitation.status} @@ -468,9 +595,19 @@ const styles = StyleSheet.create({ alignItems: 'center', marginTop: 2, }, + roleChip: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: AppColors.primaryLighter, + paddingHorizontal: Spacing.sm, + paddingVertical: 2, + borderRadius: BorderRadius.full, + gap: 2, + }, invitationRole: { fontSize: FontSizes.xs, - color: AppColors.textSecondary, + color: AppColors.primary, + fontWeight: FontWeights.medium, }, statusDot: { width: 6, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 2f9881c..257e1fb 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -157,22 +157,15 @@ export default function HomeScreen() { setError(null); try { const onboardingCompleted = await api.isOnboardingCompleted(); - const response = await api.getAllBeneficiaries(); - let allBeneficiaries: Beneficiary[] = []; + // Use only local beneficiaries (no API fetch) + setBeneficiaries(localBeneficiaries); - if (response.ok && response.data) { - allBeneficiaries = [...response.data]; + if (!currentBeneficiary && localBeneficiaries.length > 0) { + setCurrentBeneficiary(localBeneficiaries[0]); } - allBeneficiaries = [...allBeneficiaries, ...localBeneficiaries]; - setBeneficiaries(allBeneficiaries); - - if (!currentBeneficiary && allBeneficiaries.length > 0) { - setCurrentBeneficiary(allBeneficiaries[0]); - } - - if (allBeneficiaries.length === 0 && !onboardingCompleted) { + if (localBeneficiaries.length === 0 && !onboardingCompleted) { router.replace({ pathname: '/(auth)/add-loved-one', params: { email: user?.email || '' }, @@ -181,11 +174,7 @@ export default function HomeScreen() { } } catch (err) { console.error('Failed to load beneficiaries:', err); - if (localBeneficiaries.length > 0) { - setBeneficiaries(localBeneficiaries); - } else { - setError('Failed to load beneficiaries'); - } + setBeneficiaries(localBeneficiaries); } finally { setIsLoading(false); } diff --git a/app/(tabs)/profile/index.tsx b/app/(tabs)/profile/index.tsx index 6ddf6fe..cd9fa82 100644 --- a/app/(tabs)/profile/index.tsx +++ b/app/(tabs)/profile/index.tsx @@ -16,6 +16,7 @@ import * as Clipboard from 'expo-clipboard'; import { router } from 'expo-router'; import { useAuth } from '@/contexts/AuthContext'; import { ProfileDrawer } from '@/components/ProfileDrawer'; +import { useToast } from '@/components/ui/Toast'; import { AppColors, BorderRadius, @@ -42,6 +43,7 @@ const generateInviteCode = (identifier: string): string => { export default function ProfileScreen() { const { user, logout } = useAuth(); + const toast = useToast(); // Drawer state const [drawerVisible, setDrawerVisible] = useState(false); @@ -129,7 +131,7 @@ export default function ProfileScreen() { const handleCopyInviteCode = async () => { await Clipboard.setStringAsync(inviteCode); - Alert.alert('Copied!', `Invite code "${inviteCode}" copied to clipboard`); + toast.success(`Invite code "${inviteCode}" copied to clipboard`); }; return ( diff --git a/backend/src/routes/invitations.js b/backend/src/routes/invitations.js index 265068e..365173b 100644 --- a/backend/src/routes/invitations.js +++ b/backend/src/routes/invitations.js @@ -3,6 +3,7 @@ const router = express.Router(); const jwt = require('jsonwebtoken'); const crypto = require('crypto'); const { supabase } = require('../config/supabase'); +const { sendInvitationEmail } = require('../services/email'); /** * Middleware to verify JWT token @@ -104,10 +105,37 @@ router.post('/', async (req, res) => { return res.status(500).json({ error: 'Failed to create invitation' }); } - // TODO: Send invitation email if email provided + // Send invitation email if email provided + let emailSent = false; + if (email) { + // Get inviter and beneficiary names for email + const { data: inviter } = await supabase + .from('users') + .select('first_name, last_name, email') + .eq('id', userId) + .single(); + + const { data: beneficiaryData } = await supabase + .from('users') + .select('first_name, last_name') + .eq('id', beneficiaryId) + .single(); + + const inviterName = inviter ? `${inviter.first_name || ''} ${inviter.last_name || ''}`.trim() || inviter.email : null; + const beneficiaryName = beneficiaryData ? `${beneficiaryData.first_name || ''} ${beneficiaryData.last_name || ''}`.trim() : null; + + emailSent = await sendInvitationEmail({ + email: email.toLowerCase(), + inviterName, + beneficiaryName, + role, + inviteCode: inviteToken + }); + } res.json({ success: true, + emailSent, invitation: { id: invitation.id, code: invitation.token, @@ -289,6 +317,69 @@ router.get('/', async (req, res) => { } }); +/** + * PATCH /api/invitations/:id + * Updates invitation role + */ +router.patch('/:id', async (req, res) => { + try { + const userId = req.user.userId; + const invitationId = parseInt(req.params.id, 10); + const { role } = req.body; + + if (!role || !['caretaker', 'guardian'].includes(role)) { + return res.status(400).json({ error: 'Invalid role. Must be: caretaker or guardian' }); + } + + // Check invitation belongs to user + const { data: invitation, error: findError } = await supabase + .from('invitations') + .select('*') + .eq('id', invitationId) + .eq('invited_by', userId) + .single(); + + if (findError || !invitation) { + return res.status(404).json({ error: 'Invitation not found' }); + } + + // Update invitation role + const { data: updated, error } = await supabase + .from('invitations') + .update({ role }) + .eq('id', invitationId) + .select() + .single(); + + if (error) { + console.error('Update invitation error:', error); + return res.status(500).json({ error: 'Failed to update invitation' }); + } + + // If invitation was already accepted, also update user_access + if (invitation.accepted_by) { + await supabase + .from('user_access') + .update({ role }) + .eq('accessor_id', invitation.accepted_by) + .eq('beneficiary_id', invitation.beneficiary_id); + } + + res.json({ + success: true, + invitation: { + id: updated.id, + role: updated.role, + email: updated.email + } + }); + + } catch (error) { + console.error('Update invitation error:', error); + res.status(500).json({ error: error.message }); + } +}); + /** * DELETE /api/invitations/:id * Revokes a pending invitation diff --git a/backend/src/services/email.js b/backend/src/services/email.js index 7e21e5e..fc12466 100644 --- a/backend/src/services/email.js +++ b/backend/src/services/email.js @@ -216,8 +216,124 @@ If you didn't request this code, you can safely ignore this email. } } +/** + * Send invitation email to share beneficiary access + */ +async function sendInvitationEmail({ email, inviterName, beneficiaryName, role, inviteCode }) { + const frontendUrl = process.env.FRONTEND_URL || 'https://wellnuo.smartlaunchhub.com'; + const acceptLink = `${frontendUrl}/accept-invite?code=${inviteCode}`; + const roleText = role === 'guardian' ? 'Guardian (full access)' : 'Caretaker (view only)'; + + const htmlContent = ` + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

WellNuo

+

Elderly Care Monitoring

+
+

You've been invited!

+ +

+ ${inviterName || 'Someone'} has invited you to help monitor + ${beneficiaryName || 'their loved one'} on WellNuo. +

+ + +
+

Your role:

+

${roleText}

+
+ + +
+

Your invitation code:

+

${inviteCode}

+
+ + + + + + +
+ Accept Invitation +
+ +

+ This invitation expires in 7 days. +

+
+

+ If you didn't expect this invitation, you can safely ignore this email. +

+
+
+ + + `; + + const textContent = ` +WellNuo - You've been invited! + +${inviterName || 'Someone'} has invited you to help monitor ${beneficiaryName || 'their loved one'} on WellNuo. + +Your role: ${roleText} + +Your invitation code: ${inviteCode} + +Accept your invitation: ${acceptLink} + +This invitation expires in 7 days. + +If you didn't expect this invitation, you can safely ignore this email. + +WellNuo - Elderly Care Monitoring + `; + + try { + await sendEmail({ + to: email, + subject: `${inviterName || 'Someone'} invited you to WellNuo`, + htmlContent, + textContent + }); + return true; + } catch (error) { + console.error('Failed to send invitation email:', error); + return false; + } +} + module.exports = { sendEmail, sendPasswordResetEmail, - sendOTPEmail + sendOTPEmail, + sendInvitationEmail }; diff --git a/components/ui/DevModeToggle.tsx b/components/ui/DevModeToggle.tsx new file mode 100644 index 0000000..1b1702f --- /dev/null +++ b/components/ui/DevModeToggle.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { View, Text, Switch, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { + AppColors, + BorderRadius, + FontSizes, + Spacing, + FontWeights, +} from '@/constants/theme'; + +interface DevModeToggleProps { + value: boolean; + onValueChange: (value: boolean) => void; + label?: string; + hint?: string; +} + +export function DevModeToggle({ + value, + onValueChange, + label = 'Developer Mode', + hint = 'Show WebView dashboard', +}: DevModeToggleProps) { + return ( + + + + + {label} + {hint} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: AppColors.warningLight, + borderRadius: BorderRadius.lg, + padding: Spacing.md, + borderWidth: 1, + borderColor: AppColors.warning, + }, + left: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.md, + }, + label: { + fontSize: FontSizes.sm, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + }, + hint: { + fontSize: FontSizes.xs, + color: AppColors.textSecondary, + }, +}); diff --git a/services/api.ts b/services/api.ts index d900754..f84ee3c 100644 --- a/services/api.ts +++ b/services/api.ts @@ -653,6 +653,41 @@ class ApiService { }; } } + + // Update invitation role + async updateInvitation(invitationId: string, role: 'caretaker' | 'guardian'): Promise> { + const token = await this.getToken(); + if (!token) { + return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } }; + } + + try { + const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ role }), + }); + + const data = await response.json(); + + if (response.ok) { + return { data, ok: true }; + } + + return { + ok: false, + error: { message: data.error || 'Failed to update invitation' }, + }; + } catch (error) { + return { + ok: false, + error: { message: 'Network error. Please check your connection.' }, + }; + } + } } export const api = new ApiService(); diff --git a/types/index.ts b/types/index.ts index 11979ef..4c96815 100644 --- a/types/index.ts +++ b/types/index.ts @@ -79,6 +79,15 @@ export interface Beneficiary { equipmentStatus?: EquipmentStatus; trackingNumber?: string; // Shipping tracking number isDemo?: boolean; // Demo mode flag + // Invitations for sharing access + invitations?: { + id: string; + email: string; + role: string; + label?: string; + status: string; + created_at: string; + }[]; } // Dashboard API response