Replace Alert with Toast for invite code copy, rename Share to Access

This commit is contained in:
Sergei 2026-01-01 13:41:34 -08:00
parent ad35dac850
commit f6a2d5e687
9 changed files with 521 additions and 119 deletions

View File

@ -14,10 +14,8 @@ import {
Platform, Platform,
Animated, Animated,
ActivityIndicator, ActivityIndicator,
Switch,
} from 'react-native'; } from 'react-native';
import { WebView } from 'react-native-webview'; import { WebView } from 'react-native-webview';
import * as SecureStore from 'expo-secure-store';
import { useLocalSearchParams, router } from 'expo-router'; import { useLocalSearchParams, router } from 'expo-router';
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';
@ -31,6 +29,7 @@ import { FullScreenError } from '@/components/ui/ErrorMessage';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { SubscriptionPayment } from '@/components/SubscriptionPayment'; import { SubscriptionPayment } from '@/components/SubscriptionPayment';
import { useToast } from '@/components/ui/Toast'; import { useToast } from '@/components/ui/Toast';
import { DevModeToggle } from '@/components/ui/DevModeToggle';
import MockDashboard from '@/components/MockDashboard'; import MockDashboard from '@/components/MockDashboard';
import { import {
AppColors, 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 // WebView Dashboard URL - uses test NDK account for demo data
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard'; 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 // Starter Kit info
const STARTER_KIT = { const STARTER_KIT = {
@ -472,21 +473,25 @@ export default function BeneficiaryDetailScreen() {
loadBeneficiary(); loadBeneficiary();
}, [loadBeneficiary]); }, [loadBeneficiary]);
// Load credentials for WebView // Load test credentials for WebView (anandk account has sensor data)
useEffect(() => { useEffect(() => {
const loadCredentials = async () => { const loadTestCredentials = async () => {
try { try {
const token = await SecureStore.getItemAsync('accessToken'); // Always use anandk test account for WebView dashboard
const user = await SecureStore.getItemAsync('userName'); const response = await api.login(TEST_NDK_USER, TEST_NDK_PASSWORD);
const uid = await SecureStore.getItemAsync('userId'); if (response.ok && response.data) {
setAuthToken(token); setAuthToken(response.data.token);
setUserName(user); setUserName(TEST_NDK_USER);
setUserId(uid); 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) { } 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) // 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 // Render based on setup state
const renderContent = () => { const renderContent = () => {
@ -711,14 +715,19 @@ export default function BeneficiaryDetailScreen() {
case 'ready': case 'ready':
default: default:
// WebView mode - uses test NDK deployment for demo data // WebView mode - uses test anandk account for all beneficiaries
if (showWebView) { if (showWebView) {
const webViewUrl = `${DASHBOARD_URL}?deployment_id=${TEST_NDK_DEPLOYMENT_ID}`; // Token is injected via injectedJavaScript, no need for URL params
return ( return (
<View style={styles.webViewContainer}> <View style={styles.webViewContainer}>
{/* Developer Toggle - at top */}
<View style={styles.webViewToggleTop}>
<DevModeToggle value={showWebView} onValueChange={setShowWebView} />
</View>
{/* WebView below */}
<WebView <WebView
ref={webViewRef} ref={webViewRef}
source={{ uri: webViewUrl }} source={{ uri: DASHBOARD_URL }}
style={styles.webView} style={styles.webView}
javaScriptEnabled={true} javaScriptEnabled={true}
domStorageEnabled={true} domStorageEnabled={true}
@ -733,24 +742,6 @@ export default function BeneficiaryDetailScreen() {
</View> </View>
)} )}
/> />
{/* Developer Toggle - always visible to switch back */}
<View style={styles.webViewToggleOverlay}>
<View style={styles.devToggleCard}>
<View style={styles.devToggleLeft}>
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
<View>
<Text style={styles.devToggleLabel}>Developer Mode</Text>
<Text style={styles.devToggleHint}>Show WebView dashboard</Text>
</View>
</View>
<Switch
value={showWebView}
onValueChange={setShowWebView}
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
thumbColor={showWebView ? AppColors.primary : AppColors.textMuted}
/>
</View>
</View>
</View> </View>
); );
} }
@ -770,20 +761,8 @@ export default function BeneficiaryDetailScreen() {
} }
> >
{/* Developer Toggle for WebView */} {/* Developer Toggle for WebView */}
<View style={styles.devToggleCard}> <View style={styles.devToggleWrapper}>
<View style={styles.devToggleLeft}> <DevModeToggle value={showWebView} onValueChange={setShowWebView} />
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
<View>
<Text style={styles.devToggleLabel}>Developer Mode</Text>
<Text style={styles.devToggleHint}>Show WebView dashboard</Text>
</View>
</View>
<Switch
value={showWebView}
onValueChange={setShowWebView}
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
thumbColor={showWebView ? AppColors.primary : AppColors.textMuted}
/>
</View> </View>
{/* Activity Dashboard */} {/* Activity Dashboard */}
@ -803,18 +782,15 @@ export default function BeneficiaryDetailScreen() {
{/* Avatar + Name */} {/* Avatar + Name */}
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<View style={styles.headerAvatarWrapper}> {beneficiary.avatar ? (
{beneficiary.avatar ? ( <Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} /> ) : (
) : ( <View style={styles.headerAvatar}>
<View style={styles.headerAvatar}> <Text style={styles.headerAvatarText}>
<Text style={styles.headerAvatarText}> {beneficiary.name.charAt(0).toUpperCase()}
{beneficiary.name.charAt(0).toUpperCase()} </Text>
</Text> </View>
</View> )}
)}
<View style={[styles.headerStatusDot, { backgroundColor: statusColor }]} />
</View>
<Text style={styles.headerTitle}>{beneficiary.name}</Text> <Text style={styles.headerTitle}>{beneficiary.name}</Text>
</View> </View>
@ -845,7 +821,7 @@ export default function BeneficiaryDetailScreen() {
}} }}
> >
<Ionicons name="share-outline" size={20} color={AppColors.textPrimary} /> <Ionicons name="share-outline" size={20} color={AppColors.textPrimary} />
<Text style={styles.dropdownItemText}>Share</Text> <Text style={styles.dropdownItemText}>Access</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
@ -1071,41 +1047,19 @@ const styles = StyleSheet.create({
borderWidth: 2, borderWidth: 2,
borderColor: AppColors.background, borderColor: AppColors.background,
}, },
// Developer Toggle // Developer Toggle Wrapper
devToggleCard: { devToggleWrapper: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: AppColors.warningLight,
borderRadius: BorderRadius.lg,
padding: Spacing.md,
marginBottom: Spacing.lg, marginBottom: Spacing.lg,
borderWidth: 1,
borderColor: AppColors.warning,
},
devToggleLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
},
devToggleLabel: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
devToggleHint: {
fontSize: FontSizes.xs,
color: AppColors.textSecondary,
}, },
// WebView // WebView
webViewContainer: { webViewContainer: {
flex: 1, flex: 1,
}, },
webViewToggleOverlay: { webViewToggleTop: {
position: 'absolute', paddingHorizontal: Spacing.md,
bottom: 100, // Above tab bar paddingTop: Spacing.md,
left: Spacing.md, paddingBottom: Spacing.sm,
right: Spacing.md, backgroundColor: AppColors.background,
}, },
webView: { webView: {
flex: 1, flex: 1,

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -17,6 +17,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router'; import { router, useLocalSearchParams } from 'expo-router';
import { useBeneficiary } from '@/contexts/BeneficiaryContext'; import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { useToast } from '@/components/ui/Toast';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { import {
AppColors, AppColors,
@ -28,6 +29,12 @@ import {
type Role = 'caretaker' | 'guardian'; type Role = 'caretaker' | 'guardian';
// Local beneficiaries have timestamp-based IDs (>1000000000)
const isLocalBeneficiary = (id: string | number): boolean => {
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
return numId > 1000000000;
};
interface Invitation { interface Invitation {
id: string; id: string;
email: string; email: string;
@ -39,8 +46,9 @@ interface Invitation {
export default function ShareAccessScreen() { export default function ShareAccessScreen() {
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const { currentBeneficiary } = useBeneficiary(); const { currentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary();
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [role, setRole] = useState<Role>('caretaker'); const [role, setRole] = useState<Role>('caretaker');
@ -52,16 +60,34 @@ export default function ShareAccessScreen() {
const beneficiaryName = currentBeneficiary?.name || 'this person'; const beneficiaryName = currentBeneficiary?.name || 'this person';
const currentUserEmail = user?.email?.toLowerCase(); 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 // Load invitations on mount
useEffect(() => { useEffect(() => {
if (id) { if (id) {
loadInvitations(); loadInvitations();
} }
}, [id]); }, [id, isLocal, localBeneficiary]);
const loadInvitations = async () => { const loadInvitations = async () => {
if (!id) return; if (!id) return;
// For local beneficiaries, use stored invitations
if (isLocal && localBeneficiary) {
setInvitations(localBeneficiary.invitations || []);
setIsLoadingInvitations(false);
setRefreshing(false);
return;
}
// For API beneficiaries
try { try {
const response = await api.getInvitations(id); const response = await api.getInvitations(id);
if (response.ok && response.data) { if (response.ok && response.data) {
@ -113,6 +139,35 @@ export default function ShareAccessScreen() {
setIsLoading(true); 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 { try {
const response = await api.sendInvitation({ const response = await api.sendInvitation({
beneficiaryId: id!, beneficiaryId: id!,
@ -124,7 +179,7 @@ export default function ShareAccessScreen() {
setEmail(''); setEmail('');
setRole('caretaker'); setRole('caretaker');
loadInvitations(); loadInvitations();
Alert.alert('Success', `Invitation sent to ${trimmedEmail}`); toast.success('Success', `Invitation sent to ${trimmedEmail}`);
} else { } else {
Alert.alert('Error', response.error?.message || 'Failed to send invitation'); Alert.alert('Error', response.error?.message || 'Failed to send invitation');
} }
@ -146,10 +201,28 @@ export default function ShareAccessScreen() {
text: 'Remove', text: 'Remove',
style: 'destructive', style: 'destructive',
onPress: async () => { 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 { try {
const response = await api.deleteInvitation(invitation.id); const response = await api.deleteInvitation(invitation.id);
if (response.ok) { if (response.ok) {
loadInvitations(); loadInvitations();
toast.success('Removed', 'Access removed successfully');
} else { } else {
Alert.alert('Error', 'Failed to remove access'); Alert.alert('Error', 'Failed to remove access');
} }
@ -167,6 +240,54 @@ export default function ShareAccessScreen() {
return 'Caretaker'; 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 => { const getStatusColor = (status: string): string => {
switch (status) { switch (status) {
case 'accepted': return AppColors.success; case 'accepted': return AppColors.success;
@ -282,9 +403,15 @@ export default function ShareAccessScreen() {
{invitation.email} {invitation.email}
</Text> </Text>
<View style={styles.invitationMeta}> <View style={styles.invitationMeta}>
<Text style={styles.invitationRole}> <TouchableOpacity
{getRoleLabel(invitation.role)} style={styles.roleChip}
</Text> onPress={() => handleChangeRole(invitation)}
>
<Text style={styles.invitationRole}>
{getRoleLabel(invitation.role)}
</Text>
<Ionicons name="chevron-down" size={12} color={AppColors.primary} />
</TouchableOpacity>
<View style={[styles.statusDot, { backgroundColor: getStatusColor(invitation.status) }]} /> <View style={[styles.statusDot, { backgroundColor: getStatusColor(invitation.status) }]} />
<Text style={[styles.invitationStatus, { color: getStatusColor(invitation.status) }]}> <Text style={[styles.invitationStatus, { color: getStatusColor(invitation.status) }]}>
{invitation.status} {invitation.status}
@ -468,9 +595,19 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
marginTop: 2, marginTop: 2,
}, },
roleChip: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.primaryLighter,
paddingHorizontal: Spacing.sm,
paddingVertical: 2,
borderRadius: BorderRadius.full,
gap: 2,
},
invitationRole: { invitationRole: {
fontSize: FontSizes.xs, fontSize: FontSizes.xs,
color: AppColors.textSecondary, color: AppColors.primary,
fontWeight: FontWeights.medium,
}, },
statusDot: { statusDot: {
width: 6, width: 6,

View File

@ -157,22 +157,15 @@ export default function HomeScreen() {
setError(null); setError(null);
try { try {
const onboardingCompleted = await api.isOnboardingCompleted(); 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) { if (!currentBeneficiary && localBeneficiaries.length > 0) {
allBeneficiaries = [...response.data]; setCurrentBeneficiary(localBeneficiaries[0]);
} }
allBeneficiaries = [...allBeneficiaries, ...localBeneficiaries]; if (localBeneficiaries.length === 0 && !onboardingCompleted) {
setBeneficiaries(allBeneficiaries);
if (!currentBeneficiary && allBeneficiaries.length > 0) {
setCurrentBeneficiary(allBeneficiaries[0]);
}
if (allBeneficiaries.length === 0 && !onboardingCompleted) {
router.replace({ router.replace({
pathname: '/(auth)/add-loved-one', pathname: '/(auth)/add-loved-one',
params: { email: user?.email || '' }, params: { email: user?.email || '' },
@ -181,11 +174,7 @@ export default function HomeScreen() {
} }
} catch (err) { } catch (err) {
console.error('Failed to load beneficiaries:', err); console.error('Failed to load beneficiaries:', err);
if (localBeneficiaries.length > 0) { setBeneficiaries(localBeneficiaries);
setBeneficiaries(localBeneficiaries);
} else {
setError('Failed to load beneficiaries');
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@ -16,6 +16,7 @@ import * as Clipboard from 'expo-clipboard';
import { router } from 'expo-router'; import { router } from 'expo-router';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { ProfileDrawer } from '@/components/ProfileDrawer'; import { ProfileDrawer } from '@/components/ProfileDrawer';
import { useToast } from '@/components/ui/Toast';
import { import {
AppColors, AppColors,
BorderRadius, BorderRadius,
@ -42,6 +43,7 @@ const generateInviteCode = (identifier: string): string => {
export default function ProfileScreen() { export default function ProfileScreen() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const toast = useToast();
// Drawer state // Drawer state
const [drawerVisible, setDrawerVisible] = useState(false); const [drawerVisible, setDrawerVisible] = useState(false);
@ -129,7 +131,7 @@ export default function ProfileScreen() {
const handleCopyInviteCode = async () => { const handleCopyInviteCode = async () => {
await Clipboard.setStringAsync(inviteCode); await Clipboard.setStringAsync(inviteCode);
Alert.alert('Copied!', `Invite code "${inviteCode}" copied to clipboard`); toast.success(`Invite code "${inviteCode}" copied to clipboard`);
}; };
return ( return (

View File

@ -3,6 +3,7 @@ const router = express.Router();
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const crypto = require('crypto'); const crypto = require('crypto');
const { supabase } = require('../config/supabase'); const { supabase } = require('../config/supabase');
const { sendInvitationEmail } = require('../services/email');
/** /**
* Middleware to verify JWT token * Middleware to verify JWT token
@ -104,10 +105,37 @@ router.post('/', async (req, res) => {
return res.status(500).json({ error: 'Failed to create invitation' }); 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({ res.json({
success: true, success: true,
emailSent,
invitation: { invitation: {
id: invitation.id, id: invitation.id,
code: invitation.token, 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 * DELETE /api/invitations/:id
* Revokes a pending invitation * Revokes a pending invitation

View File

@ -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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #F5F7FA; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;">
<table width="100%" cellspacing="0" cellpadding="0" style="background-color: #F5F7FA;">
<tr>
<td align="center" style="padding: 48px 24px;">
<table width="100%" cellspacing="0" cellpadding="0" style="max-width: 500px; background-color: #FFFFFF; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #4A90D9 0%, #357ABD 100%); padding: 32px; text-align: center;">
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #FFFFFF;">WellNuo</h1>
<p style="margin: 8px 0 0 0; font-size: 14px; color: rgba(255,255,255,0.9);">Elderly Care Monitoring</p>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding: 32px;">
<h2 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 600; color: #333333;">You've been invited!</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #666666; line-height: 1.6;">
<strong>${inviterName || 'Someone'}</strong> has invited you to help monitor
<strong>${beneficiaryName || 'their loved one'}</strong> on WellNuo.
</p>
<!-- Role Badge -->
<div style="background-color: #F0F7FF; border-radius: 8px; padding: 16px; margin-bottom: 24px;">
<p style="margin: 0; font-size: 13px; color: #666666;">Your role:</p>
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #4A90D9;">${roleText}</p>
</div>
<!-- Invite Code -->
<div style="background-color: #F5F7FA; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 24px;">
<p style="margin: 0 0 8px 0; font-size: 13px; color: #666666;">Your invitation code:</p>
<p style="margin: 0; font-size: 28px; font-weight: 700; letter-spacing: 3px; color: #333333;">${inviteCode}</p>
</div>
<!-- CTA Button -->
<table width="100%" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<a href="${acceptLink}" style="display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #4A90D9 0%, #357ABD 100%); color: #FFFFFF; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: 600;">Accept Invitation</a>
</td>
</tr>
</table>
<p style="margin: 24px 0 0 0; font-size: 13px; color: #999999; text-align: center;">
This invitation expires in 7 days.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px; background-color: #F5F7FA; text-align: center;">
<p style="margin: 0; font-size: 12px; color: #999999;">
If you didn't expect this invitation, you can safely ignore this email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
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 = { module.exports = {
sendEmail, sendEmail,
sendPasswordResetEmail, sendPasswordResetEmail,
sendOTPEmail sendOTPEmail,
sendInvitationEmail
}; };

View File

@ -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 (
<View style={styles.container}>
<View style={styles.left}>
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
<View>
<Text style={styles.label}>{label}</Text>
<Text style={styles.hint}>{hint}</Text>
</View>
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
thumbColor={value ? AppColors.primary : AppColors.textMuted}
/>
</View>
);
}
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,
},
});

View File

@ -653,6 +653,41 @@ class ApiService {
}; };
} }
} }
// Update invitation role
async updateInvitation(invitationId: string, role: 'caretaker' | 'guardian'): Promise<ApiResponse<{ success: boolean; invitation: { id: string; role: string; email: string } }>> {
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(); export const api = new ApiService();

View File

@ -79,6 +79,15 @@ export interface Beneficiary {
equipmentStatus?: EquipmentStatus; equipmentStatus?: EquipmentStatus;
trackingNumber?: string; // Shipping tracking number trackingNumber?: string; // Shipping tracking number
isDemo?: boolean; // Demo mode flag 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 // Dashboard API response