Add status badges for beneficiaries list
- Monitoring badge: equipment active + subscription active - Get kit badge: user hasn't ordered equipment yet - Equipment status badges: ordered, shipped, delivered - No subscription warning when equipment works but no sub - Stripe subscription caching in backend (hourly sync) - BeneficiaryMenu with edit/share/archive/delete actions
This commit is contained in:
parent
6e277ca940
commit
657737e5a4
@ -8,6 +8,7 @@ import {
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
Spacing,
|
||||
Shadows,
|
||||
} from '@/constants/theme';
|
||||
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
@ -222,12 +223,12 @@ export default function EquipmentScreen() {
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Equipment</Text>
|
||||
<Text style={styles.headerTitle}>Sensors</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||
<Text style={styles.loadingText}>Loading devices...</Text>
|
||||
<Text style={styles.loadingText}>Loading sensors...</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@ -240,10 +241,17 @@ export default function EquipmentScreen() {
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Equipment</Text>
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleAddDevice}>
|
||||
<Ionicons name="add" size={24} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Sensors</Text>
|
||||
<View style={styles.headerRight}>
|
||||
<TouchableOpacity style={styles.addButton} onPress={handleAddDevice}>
|
||||
<Ionicons name="add" size={24} color={AppColors.primary} />
|
||||
</TouchableOpacity>
|
||||
<BeneficiaryMenu
|
||||
beneficiaryId={id || ''}
|
||||
userRole={currentBeneficiary?.role}
|
||||
currentPage="sensors"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
@ -394,6 +402,11 @@ const styles = StyleSheet.create({
|
||||
placeholder: {
|
||||
width: 32,
|
||||
},
|
||||
headerRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
addButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
|
||||
@ -356,6 +356,7 @@ export default function BeneficiaryDetailScreen() {
|
||||
|
||||
<BeneficiaryMenu
|
||||
beneficiaryId={id || ''}
|
||||
userRole={beneficiary?.role}
|
||||
onEdit={handleEditPress}
|
||||
onRemove={handleDeleteBeneficiary}
|
||||
/>
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
FontWeights,
|
||||
Spacing,
|
||||
} from '@/constants/theme';
|
||||
import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
|
||||
|
||||
type Role = 'caretaker' | 'guardian';
|
||||
|
||||
@ -306,7 +307,11 @@ export default function ShareAccessScreen() {
|
||||
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Access</Text>
|
||||
<View style={styles.placeholder} />
|
||||
<BeneficiaryMenu
|
||||
beneficiaryId={id || ''}
|
||||
userRole={currentBeneficiary?.role}
|
||||
currentPage="access"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
@ -459,9 +464,6 @@ const styles = StyleSheet.create({
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
placeholder: {
|
||||
width: 32,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
@ -459,6 +459,7 @@ export default function SubscriptionScreen() {
|
||||
<Text style={styles.headerTitle}>Subscription</Text>
|
||||
<BeneficiaryMenu
|
||||
beneficiaryId={id || ''}
|
||||
userRole={beneficiary?.role}
|
||||
currentPage="subscription"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@ -37,6 +37,13 @@ interface BeneficiaryCardProps {
|
||||
|
||||
// Equipment status config
|
||||
const equipmentStatusConfig = {
|
||||
none: {
|
||||
icon: 'cart-outline' as const,
|
||||
label: 'Get kit',
|
||||
sublabel: 'Order equipment',
|
||||
color: AppColors.textMuted,
|
||||
bgColor: AppColors.backgroundSecondary,
|
||||
},
|
||||
ordered: {
|
||||
icon: 'cube-outline' as const,
|
||||
label: 'Kit ordered',
|
||||
@ -65,33 +72,22 @@ const equipmentStatusConfig = {
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successLight,
|
||||
},
|
||||
demo: {
|
||||
icon: 'pulse-outline' as const,
|
||||
label: 'Monitoring',
|
||||
sublabel: 'Demo mode',
|
||||
color: AppColors.success,
|
||||
bgColor: AppColors.successLight,
|
||||
},
|
||||
};
|
||||
|
||||
function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardProps) {
|
||||
const equipmentStatus = beneficiary.equipmentStatus;
|
||||
const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus);
|
||||
const isMonitoring = equipmentStatus && ['active', 'demo'].includes(equipmentStatus);
|
||||
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : null;
|
||||
const isEquipmentActive = equipmentStatus === 'active' || equipmentStatus === 'demo';
|
||||
const hasSubscription = isSubscriptionActive(beneficiary.subscription);
|
||||
const needsKit = !equipmentStatus || equipmentStatus === 'none';
|
||||
|
||||
// Check if has devices/equipment connected
|
||||
const hasDevices = beneficiary.hasDevices ||
|
||||
(beneficiary.devices && beneficiary.devices.length > 0) ||
|
||||
beneficiary.device_id;
|
||||
// Monitoring = equipment works + subscription active (all good!)
|
||||
const isMonitoring = isEquipmentActive && hasSubscription;
|
||||
|
||||
// Show subscription warning if:
|
||||
// 1. Has devices connected (equipment is installed)
|
||||
// 2. NOT awaiting equipment delivery
|
||||
// 3. Subscription is missing or not active
|
||||
const hasNoSubscription = hasDevices && !isAwaitingEquipment && (
|
||||
!isSubscriptionActive(beneficiary.subscription)
|
||||
);
|
||||
// No subscription warning: equipment works but no subscription
|
||||
const hasNoSubscription = isEquipmentActive && !hasSubscription;
|
||||
|
||||
const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none;
|
||||
|
||||
// Check if avatar is valid (not empty, null, or placeholder)
|
||||
const hasValidAvatar = beneficiary.avatar &&
|
||||
@ -129,6 +125,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
{/* Name and Status */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>{beneficiary.name}</Text>
|
||||
{/* User's role for this beneficiary */}
|
||||
{beneficiary.role && (
|
||||
<Text style={styles.roleText}>
|
||||
You: {beneficiary.role.charAt(0).toUpperCase() + beneficiary.role.slice(1)}
|
||||
</Text>
|
||||
)}
|
||||
{/* Equipment status badge (awaiting delivery) */}
|
||||
{isAwaitingEquipment && statusConfig && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
|
||||
@ -138,12 +140,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Monitoring badge (active/demo) */}
|
||||
{isMonitoring && statusConfig && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: statusConfig.bgColor }]}>
|
||||
<Ionicons name={statusConfig.icon} size={14} color={statusConfig.color} />
|
||||
<Text style={[styles.statusText, { color: statusConfig.color }]}>
|
||||
{statusConfig.label}
|
||||
{/* Monitoring badge (equipment works + subscription active) */}
|
||||
{isMonitoring && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: equipmentStatusConfig.active.bgColor }]}>
|
||||
<Ionicons name={equipmentStatusConfig.active.icon} size={14} color={equipmentStatusConfig.active.color} />
|
||||
<Text style={[styles.statusText, { color: equipmentStatusConfig.active.color }]}>
|
||||
{equipmentStatusConfig.active.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@ -154,6 +156,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
|
||||
<Text style={styles.noSubscriptionText}>No subscription</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Get kit badge (no equipment) */}
|
||||
{needsKit && (
|
||||
<View style={[styles.statusBadge, { backgroundColor: equipmentStatusConfig.none.bgColor }]}>
|
||||
<Ionicons name={equipmentStatusConfig.none.icon} size={14} color={equipmentStatusConfig.none.color} />
|
||||
<Text style={[styles.statusText, { color: equipmentStatusConfig.none.color }]}>
|
||||
{equipmentStatusConfig.none.label}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Action button or Arrow */}
|
||||
@ -578,6 +589,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
},
|
||||
roleText: {
|
||||
fontSize: FontSizes.xs,
|
||||
color: AppColors.textMuted,
|
||||
marginTop: 2,
|
||||
},
|
||||
noSubscriptionBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@ -91,8 +91,9 @@ app.use('/function/well-api/api', authLimiter); // Legacy API с auth
|
||||
app.use('/api/webhook/stripe', express.raw({ type: 'application/json' }));
|
||||
|
||||
// JSON body parser for other routes
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
// Increased limit for base64 avatar uploads
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// ============ ROUTES ============
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { supabase } = require('../config/supabase');
|
||||
const { sendOTPEmail } = require('../services/email');
|
||||
const storage = require('../services/storage');
|
||||
|
||||
/**
|
||||
* POST /api/auth/check-email
|
||||
@ -233,7 +234,8 @@ router.post('/verify-otp', async (req, res) => {
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
role: user.role || 'user'
|
||||
role: user.role || 'user',
|
||||
avatarUrl: user.avatar_url || null
|
||||
},
|
||||
beneficiaries
|
||||
});
|
||||
@ -312,7 +314,8 @@ router.get('/me', async (req, res) => {
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
role: user.role || 'user'
|
||||
role: user.role || 'user',
|
||||
avatarUrl: user.avatar_url || null
|
||||
},
|
||||
beneficiaries
|
||||
});
|
||||
@ -385,4 +388,108 @@ router.patch('/profile', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/auth/avatar
|
||||
* Upload/update user profile avatar
|
||||
* - Uploads to MinIO if configured
|
||||
* - Falls back to base64 in DB if MinIO not available
|
||||
*/
|
||||
router.patch('/avatar', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const userId = decoded.userId;
|
||||
|
||||
const { avatar } = req.body; // base64 string or null to remove
|
||||
|
||||
console.log('[AUTH] Avatar update:', { userId, hasAvatar: !!avatar });
|
||||
|
||||
// Validate base64 if provided
|
||||
if (avatar && !avatar.startsWith('data:image/')) {
|
||||
return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' });
|
||||
}
|
||||
|
||||
let avatarUrl = null;
|
||||
|
||||
if (avatar) {
|
||||
// Try to upload to MinIO
|
||||
if (storage.isConfigured()) {
|
||||
try {
|
||||
// Get current avatar to delete old file
|
||||
const { data: current } = await supabase
|
||||
.from('users')
|
||||
.select('avatar_url')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
// Delete old avatar from MinIO if exists
|
||||
if (current?.avatar_url && current.avatar_url.includes('minio')) {
|
||||
const oldKey = storage.extractKeyFromUrl(current.avatar_url);
|
||||
if (oldKey) {
|
||||
try {
|
||||
await storage.deleteFile(oldKey);
|
||||
} catch (e) {
|
||||
console.warn('[AUTH] Failed to delete old avatar:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload new avatar to MinIO
|
||||
const filename = `user-${userId}-${Date.now()}`;
|
||||
const result = await storage.uploadBase64Image(avatar, 'avatars/users', filename);
|
||||
avatarUrl = result.url;
|
||||
|
||||
console.log('[AUTH] Avatar uploaded to MinIO:', avatarUrl);
|
||||
} catch (uploadError) {
|
||||
console.error('[AUTH] MinIO upload failed, falling back to DB:', uploadError.message);
|
||||
// Fallback: store base64 in DB
|
||||
avatarUrl = avatar;
|
||||
}
|
||||
} else {
|
||||
// MinIO not configured - store base64 in DB
|
||||
console.log('[AUTH] MinIO not configured, storing base64 in DB');
|
||||
avatarUrl = avatar;
|
||||
}
|
||||
}
|
||||
|
||||
// Update avatar_url in users table
|
||||
const { data: user, error } = await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
avatar_url: avatarUrl,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', userId)
|
||||
.select('id, email, first_name, last_name, avatar_url')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('[AUTH] Avatar update error:', error);
|
||||
return res.status(500).json({ error: 'Failed to update avatar' });
|
||||
}
|
||||
|
||||
console.log('[AUTH] Avatar updated:', { userId, avatarUrl: user.avatar_url?.substring(0, 50) });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
avatarUrl: user.avatar_url
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AUTH] Avatar error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -4,7 +4,8 @@ import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
|
||||
|
||||
export type MenuItemId = 'edit' | 'access' | 'subscription' | 'equipment' | 'remove';
|
||||
export type MenuItemId = 'dashboard' | 'edit' | 'access' | 'subscription' | 'sensors' | 'remove';
|
||||
export type UserRole = 'custodian' | 'guardian' | 'caretaker';
|
||||
|
||||
interface MenuItem {
|
||||
id: MenuItemId;
|
||||
@ -13,16 +14,29 @@ interface MenuItem {
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
// Permissions by role (Variant B)
|
||||
// Custodian: all permissions
|
||||
// Guardian: all except remove
|
||||
// Caretaker: dashboard, edit, sensors only
|
||||
const ROLE_PERMISSIONS: Record<UserRole, MenuItemId[]> = {
|
||||
custodian: ['dashboard', 'edit', 'access', 'subscription', 'sensors', 'remove'],
|
||||
guardian: ['dashboard', 'edit', 'access', 'subscription', 'sensors'],
|
||||
caretaker: ['dashboard', 'edit', 'sensors'],
|
||||
};
|
||||
|
||||
const ALL_MENU_ITEMS: MenuItem[] = [
|
||||
{ id: 'dashboard', icon: 'grid-outline', label: 'Dashboard' },
|
||||
{ id: 'edit', icon: 'create-outline', label: 'Edit' },
|
||||
{ id: 'access', icon: 'share-outline', label: 'Access' },
|
||||
{ id: 'subscription', icon: 'diamond-outline', label: 'Subscription' },
|
||||
{ id: 'equipment', icon: 'hardware-chip-outline', label: 'Equipment' },
|
||||
{ id: 'sensors', icon: 'hardware-chip-outline', label: 'Sensors' },
|
||||
{ id: 'remove', icon: 'trash-outline', label: 'Remove', danger: true },
|
||||
];
|
||||
|
||||
interface BeneficiaryMenuProps {
|
||||
beneficiaryId: string | number;
|
||||
/** User's role for this beneficiary - determines available menu items */
|
||||
userRole?: UserRole;
|
||||
/** Which menu items to show. If not provided, shows all except current page */
|
||||
visibleItems?: MenuItemId[];
|
||||
/** Which menu item represents the current page (will be hidden) */
|
||||
@ -35,6 +49,7 @@ interface BeneficiaryMenuProps {
|
||||
|
||||
export function BeneficiaryMenu({
|
||||
beneficiaryId,
|
||||
userRole = 'caretaker', // Default to minimum permissions if not specified (security-first approach)
|
||||
visibleItems,
|
||||
currentPage,
|
||||
onEdit,
|
||||
@ -46,6 +61,9 @@ export function BeneficiaryMenu({
|
||||
setIsVisible(false);
|
||||
|
||||
switch (itemId) {
|
||||
case 'dashboard':
|
||||
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
|
||||
break;
|
||||
case 'edit':
|
||||
if (onEdit) {
|
||||
onEdit();
|
||||
@ -60,7 +78,7 @@ export function BeneficiaryMenu({
|
||||
case 'subscription':
|
||||
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`);
|
||||
break;
|
||||
case 'equipment':
|
||||
case 'sensors':
|
||||
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`);
|
||||
break;
|
||||
case 'remove':
|
||||
@ -74,13 +92,20 @@ export function BeneficiaryMenu({
|
||||
}
|
||||
};
|
||||
|
||||
// Filter menu items - only hide current page
|
||||
let menuItems = ALL_MENU_ITEMS;
|
||||
// Filter menu items based on:
|
||||
// 1. User role permissions
|
||||
// 2. Explicitly visible items (if provided)
|
||||
// 3. Current page (hide it from menu)
|
||||
const allowedByRole = ROLE_PERMISSIONS[userRole] || ROLE_PERMISSIONS.caretaker;
|
||||
|
||||
let menuItems = ALL_MENU_ITEMS.filter(item => allowedByRole.includes(item.id));
|
||||
|
||||
if (visibleItems) {
|
||||
menuItems = ALL_MENU_ITEMS.filter(item => visibleItems.includes(item.id));
|
||||
} else if (currentPage) {
|
||||
menuItems = ALL_MENU_ITEMS.filter(item => item.id !== currentPage);
|
||||
menuItems = menuItems.filter(item => visibleItems.includes(item.id));
|
||||
}
|
||||
|
||||
if (currentPage) {
|
||||
menuItems = menuItems.filter(item => item.id !== currentPage);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { File } from 'expo-file-system';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
// Callback for handling unauthorized responses (401)
|
||||
@ -344,6 +344,7 @@ class ApiService {
|
||||
// /auth/me returns { user: {...}, beneficiaries: [...] }
|
||||
// Extract user data from nested 'user' object
|
||||
const userData = profile.data.user || profile.data;
|
||||
console.log('[API] getStoredUser: userData =', JSON.stringify(userData));
|
||||
|
||||
return {
|
||||
user_id: userData.id,
|
||||
@ -460,6 +461,51 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
// Update user profile avatar on WellNuo API
|
||||
async updateProfileAvatar(imageUri: string | null): Promise<ApiResponse<{ id: string; avatarUrl: string | null }>> {
|
||||
const token = await this.getToken();
|
||||
|
||||
if (!token) {
|
||||
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||
}
|
||||
|
||||
try {
|
||||
let avatarData: string | null = null;
|
||||
|
||||
if (imageUri) {
|
||||
// Read image as base64 using new expo-file-system v19+ File API
|
||||
const file = new File(imageUri);
|
||||
const base64Data = await file.base64();
|
||||
|
||||
// Determine MIME type from URI
|
||||
const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';
|
||||
const mimeType = extension === 'png' ? 'image/png' : 'image/jpeg';
|
||||
|
||||
avatarData = `data:${mimeType};base64,${base64Data}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${WELLNUO_API_URL}/auth/avatar`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ avatar: avatarData }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: { message: data.error || 'Failed to update avatar' } };
|
||||
}
|
||||
|
||||
return { data: data.user, ok: true };
|
||||
} catch (error) {
|
||||
console.error('[API] updateProfileAvatar error:', error);
|
||||
return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
|
||||
}
|
||||
}
|
||||
|
||||
// Beneficiaries (elderly people being monitored)
|
||||
async getBeneficiaries(): Promise<ApiResponse<{ beneficiaries: Beneficiary[] }>> {
|
||||
const token = await this.getToken();
|
||||
@ -619,6 +665,7 @@ class ApiService {
|
||||
equipmentStatus: item.equipmentStatus,
|
||||
hasDevices: item.hasDevices || false,
|
||||
trackingNumber: item.trackingNumber,
|
||||
role: item.role, // User's role for this beneficiary (custodian, guardian, caretaker)
|
||||
}));
|
||||
|
||||
return { data: beneficiaries, ok: true };
|
||||
@ -675,6 +722,8 @@ class ApiService {
|
||||
equipmentStatus: data.equipmentStatus,
|
||||
hasDevices: data.hasDevices || false,
|
||||
trackingNumber: data.trackingNumber,
|
||||
// User's role for this beneficiary (custodian, guardian, caretaker)
|
||||
role: data.role,
|
||||
};
|
||||
|
||||
return { data: beneficiary, ok: true };
|
||||
@ -776,10 +825,9 @@ class ApiService {
|
||||
let base64Image: string | null = null;
|
||||
|
||||
if (imageUri) {
|
||||
// Read file as base64 using stable FileSystem API
|
||||
const base64Data = await FileSystem.readAsStringAsync(imageUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
// Read file as base64 using new expo-file-system v19+ File API
|
||||
const file = new File(imageUri);
|
||||
const base64Data = await file.base64();
|
||||
|
||||
// Determine mime type from URI extension
|
||||
const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user