363 lines
12 KiB
TypeScript
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;
|
|
},
|
|
};
|