Files

363 lines
12 KiB
TypeScript

import { Platform } from 'react-native';
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
import { storage } from '../utils/storage';
/**
* Dynamic API Base URL
*/
/**
* Dynamic API Base URL
*
* PRIORITY:
* 1. MANUAL_API_IP (Explicit override for development)
* 2. Cached Remote Config (Production backend-controlled)
* 3. Expo Constants / app.json extra.apiUrl
* 4. Auto-detected Host IP (Expo Go)
* 5. Platform-specific defaults
*/
const MANUAL_API_IP = ''; // Set this to '192.168.x.x' to override
const getBaseUrl = async () => {
const version = 'v1';
const apiPath = `/api/${version}`;
// 1. Manual Override (Highest Priority for specific IP needs)
if (MANUAL_API_IP) {
return `http://${MANUAL_API_IP}:8000${apiPath}`;
}
// 2. Try to get from cached config (Backend-controlled)
const cachedConfigStr = await storage.get('cached_mobile_config');
if (cachedConfigStr && !__DEV__) {
try {
const config = JSON.parse(cachedConfigStr);
const remoteBase = config?.connectivity?.api_base_url;
if (remoteBase && !remoteBase.includes('biiproject.com')) {
return `${remoteBase}${apiPath}`;
}
} catch {}
}
// 3. Try Expo Constants / app.json extra (Developer-controlled)
const extraApiUrl = Constants.expoConfig?.extra?.apiUrl;
if (extraApiUrl) {
const cleanBase = extraApiUrl.endsWith('/') ? extraApiUrl.slice(0, -1) : extraApiUrl;
return cleanBase.includes('/api/') ? cleanBase : `${cleanBase}${apiPath}`;
}
// 4. Auto-detect IP from Expo Go (Highest Priority for Local Development)
const hostUri = Constants.expoConfig?.hostUri ||
(Constants as any).manifest2?.extra?.expoGoConfig?.debuggerHost ||
(Constants as any).manifest?.debuggerHost;
if (hostUri) {
const ip = hostUri.split(':')[0];
if (ip && ip !== 'localhost' && ip !== '127.0.0.1' && !ip.startsWith('10.0.2.2')) {
if (!ip.includes('exp.direct') && !ip.includes('expo.dev')) {
return `http://${ip}:8000${apiPath}`;
}
}
}
// 5. Fallback for Web
if (Platform.OS === 'web') {
if (typeof window !== 'undefined' && window.location) {
const hostname = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
? 'localhost'
: window.location.hostname;
return `http://${hostname}:8000${apiPath}`;
}
}
// 6. Absolute Fallback (Laravel Sail default tunnel or localhost)
return `http://zqfwerzr7b.laravel-sail.site:8080${apiPath}`;
};
const getUrl = async (path: string) => {
const baseUrl = await getBaseUrl();
const cleanPath = path.startsWith('/') ? path : `/${path}`;
const finalUrl = `${baseUrl}${cleanPath}`;
if (__DEV__) {
console.log(`[API] Req: ${finalUrl}`);
}
return finalUrl;
};
// Helper for authorized requests
const getAuthHeaders = async () => {
const token = await storage.get('user_token');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${token}`,
};
};
// Helper for fetching with timeout and retry
const fetchWithTimeout = async (url: string, options: any = {}) => {
const cachedConfigStr = await storage.get('cached_mobile_config');
let config: any = null;
try { config = cachedConfigStr ? JSON.parse(cachedConfigStr) : null; } catch {}
let timeout = config?.connectivity?.api_timeout_ms || 30000;
let retryCount = config?.connectivity?.api_retry_count || 0;
let lastError: any;
for (let i = 0; i <= retryCount; i++) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(id);
if (response.status >= 500 && i < retryCount) {
throw new Error(`Server Error ${response.status}`);
}
return response;
} catch (error: any) {
clearTimeout(id);
lastError = error;
// Only retry on network errors or 5xx server errors
const isNetworkError = error.message === 'Network request failed' || error.name === 'AbortError';
const isServerError = error.message.includes('Server Error');
if (i < retryCount && (isNetworkError || isServerError)) {
// Exponential backoff: 1s, 2s, 4s...
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (error.name === 'AbortError') {
throw new Error('Request timed out. Please check your connection.');
}
if (error.message === 'Network request failed') {
throw new Error('Cannot connect to server. Please check your internet or VPN.');
}
throw error;
}
}
throw lastError;
};
export const ApiService = {
login: async (email: string, pass: string) => {
const response = await fetchWithTimeout(await getUrl('/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ email, password: pass }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Login failed');
return data;
},
forgotPassword: async (email: string) => {
const response = await fetchWithTimeout(await getUrl('/forgot-password'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Request failed');
return data;
},
register: async (name: string, email: string, pass: string) => {
const response = await fetchWithTimeout(await getUrl('/register'), {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ name, email, password: pass }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Registration failed');
return data;
},
getUser: async () => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/user'), {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to fetch user');
return data;
},
updateAvatar: async (uri: string) => {
const headers = await getAuthHeaders();
// FormData needs special handling for Content-Type
delete (headers as any)['Content-Type'];
const formData = new FormData();
// Ensure URI is properly formatted for Android
const photoUri = Platform.OS === 'android' ? (uri.startsWith('file://') ? uri : `file://${uri}`) : uri;
const filename = uri.split('/').pop() || 'avatar.jpg';
const match = /\.(\w+)$/.exec(filename);
const type = match ? `image/${match[1]}` : `image/jpeg`;
formData.append('avatar', {
uri: photoUri,
name: filename,
type,
} as any);
const response = await fetchWithTimeout(await getUrl('/profile/avatar'), {
method: 'POST',
headers,
body: formData,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Avatar update failed');
return data;
},
updateProfile: async (name: string, email: string) => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/profile/update'), {
method: 'POST',
headers,
body: JSON.stringify({ name, email }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Update failed');
return data;
},
updatePassword: async (current: string, newPass: string, confirmPass: string) => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/profile/password'), {
method: 'POST',
headers,
body: JSON.stringify({
current_password: current,
password: newPass,
password_confirmation: confirmPass
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Password update failed');
return data;
},
deleteAccount: async () => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/profile/delete'), {
method: 'DELETE',
headers,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Account deletion failed');
return data;
},
getDashboardData: async () => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/dashboard'), {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to fetch dashboard');
return data;
},
getAppConfig: async () => {
const response = await fetchWithTimeout(await getUrl('/app-config'), {
method: 'GET',
headers: { 'Accept': 'application/json' },
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to fetch config');
return data;
},
syncMobileConfig: async (etag?: string | null) => {
const platform = Platform.OS;
let version = '2.0.0';
try {
const cachedConfigStr = await storage.get('cached_mobile_config');
if (cachedConfigStr) {
const cached = JSON.parse(cachedConfigStr);
if (cached?.app_updates?.app_version) version = cached.app_updates.app_version;
}
} catch {}
const headers: any = { 'Accept': 'application/json' };
if (etag) headers['If-None-Match'] = etag;
const response = await fetchWithTimeout(`${await getUrl('/mobile/sync')}?p=${platform}&v=${version}`, {
method: 'GET',
headers,
});
if (response.status === 304) {
return { status: 'not_modified' };
}
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Failed to sync template');
return {
...data,
etag: response.headers.get('ETag')
};
},
reportError: async (message: string, level: string = 'error', context: any = {}) => {
try {
const cachedConfigStr = await storage.get('cached_mobile_config');
if (cachedConfigStr) {
const config = JSON.parse(cachedConfigStr);
// 1. Check if reporting is enabled
if (config.analytics_system?.crashlytics_enabled === false) return false;
// 2. Check Log Level (Priority: critical > error > warning > info > debug)
const levels: Record<string, number> = { debug: 0, info: 1, warning: 2, error: 3, critical: 4 };
const minLevel = config.analytics_system?.log_level || 'error';
if ((levels[level] || 0) < (levels[minLevel] || 3)) return false;
}
const token = await storage.get('user_token');
const headers: any = {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = await getUrl('/mobile/log');
fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ level, message, context }),
}).catch(() => {});
} catch {}
return true;
},
registerDeviceToken: async (token: string, platform: string) => {
const headers = await getAuthHeaders();
const response = await fetchWithTimeout(await getUrl('/devices/register'), {
method: 'POST',
headers,
body: JSON.stringify({ token, platform, type: 'expo' }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.message || 'Token registration failed');
return data;
},
};