diff --git a/mobile/constants/i18n/index.ts b/mobile/constants/i18n/index.ts index 4df1245..ce52231 100644 --- a/mobile/constants/i18n/index.ts +++ b/mobile/constants/i18n/index.ts @@ -6,6 +6,7 @@ import { initReactI18next } from 'react-i18next'; import ar from '../../locales/ar.json'; import en from '../../locales/en.json'; +import es from '../../locales/es.json'; import fr from '../../locales/fr.json'; import sw from '../../locales/sw.json'; @@ -13,7 +14,7 @@ export type SupportedLanguage = 'ar' | 'en' | 'es' | 'fr' | 'sw'; export const LANGUAGE_STORAGE_KEY = 'esustellar_app_language'; /** Languages written right-to-left. */ -const RTL_LANGUAGES: ReadonlySet = new Set(['ar']); +const RTL_LANGUAGES: ReadonlySet = new Set(['ar']); export const languageOptions = [ { label: 'العربية', value: 'ar' as SupportedLanguage }, @@ -33,110 +34,7 @@ const applyRTL = (lang: SupportedLanguage): void => { const resources = { ar: { translation: ar }, en: { translation: en }, - es: { - translation: { - tabs: { - home: 'Inicio', - groups: 'Grupos', - notifications: 'Notificaciones', - profile: 'Perfil', - }, - home: { - goodMorning: 'Buenos días', - goodAfternoon: 'Buenas tardes', - goodEvening: 'Buenas noches', - defaultUser: 'Usuario de EsuStellar', - totalBalance: 'Saldo total', - quickActions: 'Accesos rápidos', - balanceValue: '— XLM', - notifications: 'Notificaciones', - }, - onboarding: { - skip: 'Omitir', - next: 'Siguiente', - getStarted: 'Comenzar', - stayInformed: 'Mantente informado', - notificationBody: - 'Recibe recordatorios de fechas de vencimiento, pagos y actualizaciones del grupo para no perderte ningún momento importante.', - allowNotifications: 'Permitir notificaciones', - skipForNow: 'Omitir por ahora', - slides: { - welcome: { - eyebrow: 'Bienvenido', - title: 'Ahorra con personas de confianza', - description: - 'Lleva tu círculo de ahorro comunitario a la cadena de bloques sin perder la experiencia familiar de grupo.', - }, - transparent: { - eyebrow: 'Transparente', - title: 'Sigue cada contribución claramente', - description: - 'Mantente al tanto de los pagos, fechas de vencimiento y el progreso del grupo con actualizaciones simples en un solo lugar.', - }, - secure: { - eyebrow: 'Seguro', - title: 'Comienza con confianza', - description: - 'Conecta tu billetera Stellar, gestiona tus opciones de seguridad y recibe recordatorios útiles cuando importa.', - }, - }, - }, - settings: { - title: 'Ajustes de seguridad', - walletAddress: 'Dirección de la billetera', - copyWalletAddress: 'Copiar dirección de la billetera', - copyToast: 'Dirección copiada al portapapeles', - copyFailed: 'No se pudo copiar la dirección', - biometricAuthentication: 'Autenticación biométrica', - supported: 'Compatible', - enableBiometrics: 'Activar biometría', - useToSignIn: 'Usa {{supportedLabel}} para iniciar sesión', - checkingDeviceCapabilities: 'Comprobando capacidades del dispositivo…', - biometricsNotSupported: 'La biometría no es compatible con este dispositivo', - setUpPinFallback: - 'Configura un PIN como respaldo cuando la biometría no esté disponible.', - noBiometricsEnrolled: 'No hay biometría registrada', - setupBiometricsHelper: - 'Configura huella o reconocimiento facial en los ajustes del dispositivo y vuelve aquí para activarlo.', - about: 'Acerca de', - version: 'Versión', - build: 'Compilación', - pinFallback: 'PIN de respaldo', - pinDescription: - 'Configura un PIN de 4-6 dígitos como respaldo cuando la biometría no esté disponible.', - setUpPin: 'Configurar PIN', - enterPin: 'Ingresa tu PIN', - confirmYourPin: 'Confirma tu PIN', - pinDigits: '4-6 dígitos', - reenterPin: 'Vuelve a ingresar el PIN', - cancel: 'Cancelar', - next: 'Siguiente', - confirm: 'Confirmar', - pinSet: 'El PIN está configurado', - pinFallbackInfo: 'Se usa como respaldo cuando la biometría falla', - change: 'Cambiar', - remove: 'Eliminar', - pinMustDigits: 'El PIN debe tener 4-6 dígitos', - pinsDoNotMatch: 'Los PIN no coinciden', - biometricsEnabled: 'Autenticación biométrica activada', - biometricsDisabled: 'Autenticación biométrica desactivada', - biometricFailed: 'La verificación biométrica falló', - failedToSavePin: 'No se pudo guardar el PIN', - pinRemoved: 'PIN eliminado', - language: 'Idioma', - languageLabel: 'Elige el idioma de la aplicación', - languageChangeSuccess: 'Idioma actualizado.', - }, - profile: { - editProfile: 'Editar perfil', - settings: 'Ajustes', - disconnectWallet: 'Desconectar billetera', - }, - lock: { - tapToUnlock: 'Toca para desbloquear', - }, - }, - }, + es: { translation: es }, fr: { translation: fr }, sw: { translation: sw }, }; diff --git a/mobile/locales/es.json b/mobile/locales/es.json new file mode 100644 index 0000000..dc932bc --- /dev/null +++ b/mobile/locales/es.json @@ -0,0 +1,95 @@ +{ + "tabs": { + "home": "Inicio", + "groups": "Grupos", + "notifications": "Notificaciones", + "profile": "Perfil" + }, + "home": { + "goodMorning": "Buenos días", + "goodAfternoon": "Buenas tardes", + "goodEvening": "Buenas noches", + "defaultUser": "Usuario de EsuStellar", + "totalBalance": "Saldo total", + "quickActions": "Accesos rápidos", + "balanceValue": "— XLM", + "notifications": "Notificaciones" + }, + "onboarding": { + "skip": "Omitir", + "next": "Siguiente", + "getStarted": "Comenzar", + "stayInformed": "Mantente informado", + "notificationBody": "Recibe recordatorios de fechas de vencimiento, pagos y actualizaciones del grupo para no perderte ningún momento importante.", + "allowNotifications": "Permitir notificaciones", + "skipForNow": "Omitir por ahora", + "slides": { + "welcome": { + "eyebrow": "Bienvenido", + "title": "Ahorra con personas de confianza", + "description": "Lleva tu círculo de ahorro comunitario a la cadena de bloques sin perder la experiencia familiar de grupo." + }, + "transparent": { + "eyebrow": "Transparente", + "title": "Sigue cada contribución claramente", + "description": "Mantente al tanto de los pagos, fechas de vencimiento y el progreso del grupo con actualizaciones simples en un solo lugar." + }, + "secure": { + "eyebrow": "Seguro", + "title": "Comienza con confianza", + "description": "Conecta tu billetera Stellar, gestiona tus opciones de seguridad y recibe recordatorios útiles cuando importa." + } + } + }, + "settings": { + "title": "Ajustes de seguridad", + "walletAddress": "Dirección de la billetera", + "copyWalletAddress": "Copiar dirección de la billetera", + "copyToast": "Dirección copiada al portapapeles", + "copyFailed": "No se pudo copiar la dirección", + "biometricAuthentication": "Autenticación biométrica", + "supported": "Compatible", + "enableBiometrics": "Activar biometría", + "useToSignIn": "Usa {{supportedLabel}} para iniciar sesión", + "checkingDeviceCapabilities": "Comprobando capacidades del dispositivo…", + "biometricsNotSupported": "La biometría no es compatible con este dispositivo", + "setUpPinFallback": "Configura un PIN como respaldo cuando la biometría no esté disponible.", + "noBiometricsEnrolled": "No hay biometría registrada", + "setupBiometricsHelper": "Configura huella o reconocimiento facial en los ajustes del dispositivo y vuelve aquí para activarlo.", + "about": "Acerca de", + "version": "Versión", + "build": "Compilación", + "pinFallback": "PIN de respaldo", + "pinDescription": "Configura un PIN de 4-6 dígitos como respaldo cuando la biometría no esté disponible.", + "setUpPin": "Configurar PIN", + "enterPin": "Ingresa tu PIN", + "confirmYourPin": "Confirma tu PIN", + "pinDigits": "4-6 dígitos", + "reenterPin": "Vuelve a ingresar el PIN", + "cancel": "Cancelar", + "next": "Siguiente", + "confirm": "Confirmar", + "pinSet": "El PIN está configurado", + "pinFallbackInfo": "Se usa como respaldo cuando la biometría falla", + "change": "Cambiar", + "remove": "Eliminar", + "pinMustDigits": "El PIN debe tener 4-6 dígitos", + "pinsDoNotMatch": "Los PIN no coinciden", + "biometricsEnabled": "Autenticación biométrica activada", + "biometricsDisabled": "Autenticación biométrica desactivada", + "biometricFailed": "La verificación biométrica falló", + "failedToSavePin": "No se pudo guardar el PIN", + "pinRemoved": "PIN eliminado", + "language": "Idioma", + "languageLabel": "Elige el idioma de la aplicación", + "languageChangeSuccess": "Idioma actualizado." + }, + "profile": { + "editProfile": "Editar perfil", + "settings": "Ajustes", + "disconnectWallet": "Desconectar billetera" + }, + "lock": { + "tapToUnlock": "Toca para desbloquear" + } +} \ No newline at end of file diff --git a/mobile/services/api/transactionsApi.ts b/mobile/services/api/transactionsApi.ts index 7b82b03..6c21d64 100644 --- a/mobile/services/api/transactionsApi.ts +++ b/mobile/services/api/transactionsApi.ts @@ -24,9 +24,13 @@ class TransactionsApiService { } /** - * Get all transactions for a user + * Get all transactions for a user (paginated) */ - async getUserTransactions(userAddress: string): Promise> { + async getUserTransactions( + userAddress: string, + cursor?: string, + limit: number = 20 + ): Promise> { try { console.log(`Fetching transactions for user: ${userAddress}`); @@ -68,6 +72,59 @@ class TransactionsApiService { }; } } + + /** + * Get paginated transactions with cursor + */ + async getPaginatedTransactions( + userAddress: string, + cursor?: string, + limit: number = 20 + ): Promise> { + try { + console.log(`Fetching paginated transactions for user: ${userAddress}`); + + await new Promise(resolve => setTimeout(resolve, 800)); + + const totalMockEntities = 50; + const allMockTx: Transaction[] = Array.from({ length: totalMockEntities }, (_, i) => ({ + id: `tx_${i + 1}`, + groupId: `group_${(i % 3) + 1}`, + userAddress, + amount: (i + 1) * 100, + type: i % 2 === 0 ? 'contribution' : 'payout', + status: 'confirmed', + createdAt: new Date(Date.now() - i * 3600000).toISOString(), + txHash: `0xhash${i}${Date.now().toString(16)}`, + })); + + const pageIndex = cursor ? parseInt(cursor, 10) : 0; + const startIndex = pageIndex * limit; + const endIndex = Math.min(startIndex + limit, allMockTx.length); + const pageTx = allMockTx.slice(startIndex, endIndex); + + return { + success: true, + data: { + transactions: pageTx, + hasMore: endIndex < allMockTx.length, + nextCursor: endIndex < allMockTx.length ? String(pageIndex + 1) : undefined, + totalCount: allMockTx.length, + }, + }; + } catch (error) { + console.error('Failed to fetch paginated transactions:', error); + return { + success: false, + error: 'Failed to fetch paginated transactions', + }; + } + } } export const transactionsApi = new TransactionsApiService(); diff --git a/mobile/services/queryClient.ts b/mobile/services/queryClient.ts index 12925e1..7698d28 100644 --- a/mobile/services/queryClient.ts +++ b/mobile/services/queryClient.ts @@ -28,6 +28,7 @@ export const queryKeys = { }, transactions: { all: ['transactions'] as const, + user: (address: string) => ['transactions', 'user', address] as const, group: (groupId: string) => ['transactions', groupId] as const, }, notifications: { diff --git a/mobile/services/sync/backgroundSync.ts b/mobile/services/sync/backgroundSync.ts index 91f5414..c7c8817 100644 --- a/mobile/services/sync/backgroundSync.ts +++ b/mobile/services/sync/backgroundSync.ts @@ -130,11 +130,23 @@ class BackgroundSyncService { * Sync transactions data */ private async syncTransactions(userAddress: string) { - const result = await transactionsApi.getUserTransactions(userAddress); - if (result.success && result.data) { - queryClient.setQueryData(['transactions', 'user', userAddress], result); - console.log('[SyncService] Transactions synced successfully'); + const result = await transactionSyncService.syncTransactions(userAddress); + + if (!result.success) { + console.error('[SyncService] Transaction sync failed:', result.error); + return; } + + const existingData = queryClient.getQueryData<{ data?: Transaction[] }>( + queryKeys.transactions.user(userAddress) + ); + + queryClient.setQueryData( + queryKeys.transactions.user(userAddress), + { ...existingData, data: existingData?.data || [] } + ); + + console.log('[SyncService] Transactions synced successfully'); } /** diff --git a/mobile/services/sync/transactions.ts b/mobile/services/sync/transactions.ts new file mode 100644 index 0000000..677ed43 --- /dev/null +++ b/mobile/services/sync/transactions.ts @@ -0,0 +1,244 @@ +import { queryClient, queryKeys } from '../queryClient'; +import { transactionsApi, Transaction } from '../api/transactionsApi'; + +export interface TransactionPage { + transactions: Transaction[]; + hasMore: boolean; + nextCursor?: string; + totalCount: number; +} + +export interface SyncResult { + success: boolean; + error?: string; + syncedCount: number; + hasMore: boolean; +} + +class TransactionSyncService { + private isSyncing: boolean = false; + private syncQueue: Promise = Promise.resolve(); + + async syncTransactions( + userAddress: string, + options: { + cursor?: string; + limit?: number; + since?: string; + } = {} + ): Promise { + const { cursor, limit = 20, since } = options; + + if (this.isSyncing) { + return this.enqueueSync(userAddress, options); + } + + this.isSyncing = true; + + try { + const result = await this.fetchTransactions(userAddress, { cursor, limit }); + + if (!result.success) { + return { success: false, error: result.error, syncedCount: 0, hasMore: false }; + } + + await this.mergeTransactions(userAddress, result.data as TransactionPage, { since }); + + return { + success: true, + syncedCount: (result.data as TransactionPage)?.transactions.length || 0, + hasMore: (result.data as TransactionPage)?.hasMore || false, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to sync transactions'; + console.error('[TransactionSync] Sync failed:', errorMsg); + return { success: false, error: errorMsg, syncedCount: 0, hasMore: false }; + } finally { + this.isSyncing = false; + } + } + + private enqueueSync(userAddress: string, options: Record): Promise { + const prevPromise = this.syncQueue; + let resolveSelf: (value: SyncResult) => void; + + this.syncQueue = new Promise((resolve) => { + resolveSelf = resolve; + }); + + return prevPromise.then(() => this.syncTransactions(userAddress, options)) + .then((result) => { + resolveSelf!(result); + return result; + }) + .catch((error) => { + resolveSelf!({ success: false, error: error.message, syncedCount: 0, hasMore: false }); + return { success: false, error: error.message, syncedCount: 0, hasMore: false }; + }); + } + + private async fetchTransactions( + userAddress: string, + params: { cursor?: string; limit?: number } + ): Promise<{ success: boolean; data?: TransactionPage; error?: string }> { + try { + const existingData = queryClient.getQueryData<{ data?: Transaction[] }>( + queryKeys.transactions.user(userAddress) + ); + + const mergedTx = existingData?.data || []; + const existingIds = new Set(mergedTx.map(tx => tx.id)); + const existingTxMap = new Map(mergedTx.map(tx => [tx.id, tx])); + + const pageCount = Math.ceil(mergedTx.length / (params.limit || 20)); + let startIndex = 0; + + if (params.cursor) { + startIndex = (parseInt(params.cursor, 10) - 1) * (params.limit || 20); + } + + const paginatedTx = mergedTx.slice(startIndex, startIndex + (params.limit || 20)); + + return { + success: true, + data: { + transactions: paginatedTx, + hasMore: startIndex + (params.limit || 20) < mergedTx.length, + nextCursor: startIndex + (params.limit || 20) < mergedTx.length + ? String(pageCount + 1) + : undefined, + totalCount: mergedTx.length, + }, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to fetch transactions'; + return { success: false, error: errorMsg }; + } + } + + private async mergeTransactions( + userAddress: string, + page: TransactionPage, + options: { since?: string } + ): Promise { + const existingData = queryClient.getQueryData<{ data?: Transaction[] }>( + queryKeys.transactions.user(userAddress) + ); + + const existingTx = existingData?.data || []; + const existingTxMap = new Map(existingTx.map(tx => [tx.id, tx])); + + for (const newTx of page.transactions) { + const existingTx = existingTxMap.get(newTx.id); + + if (!existingTx || this.isNewerThan(existingTx, newTx, options.since)) { + existingTxMap.set(newTx.id, newTx); + } + } + + const merged = Array.from(existingTxMap.values()).sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + queryClient.setQueryData( + queryKeys.transactions.user(userAddress), + { ...existingData, data: merged } + ); + + queryClient.setQueryData( + queryKeys.transactions.all, + { ...existingData, data: merged } + ); + } + + private isNewerThan( + existing: Transaction, + incoming: Transaction, + since?: string + ): boolean { + if (since) { + const sinceDate = new Date(since); + const incomingDate = new Date(incoming.createdAt); + return incomingDate > sinceDate; + } + + return existing.status !== incoming.status || + existing.txHash !== incoming.txHash; + } + + async fetchDeltaUpdates( + userAddress: string, + since: string + ): Promise { + try { + const newTransactions: Transaction[] = []; + + for (let i = 0; i < 3; i++) { + newTransactions.push({ + id: `delta_tx_${Date.now()}_${i}`, + groupId: `group_${i + 1}`, + userAddress, + amount: 50 * (i + 1), + type: i % 2 === 0 ? 'contribution' : 'payout', + status: 'confirmed', + createdAt: new Date(Date.now() - Math.random() * 86400000).toISOString(), + txHash: `0xdelta${i}${Date.now().toString(16)}`, + }); + } + + const existingData = queryClient.getQueryData<{ data?: Transaction[] }>( + queryKeys.transactions.user(userAddress) + ); + + const existingTx = existingData?.data || []; + const existingIds = new Set(existingTx.map(tx => tx.id)); + + const merged = [...existingTx]; + for (const newTx of newTransactions) { + if (!existingIds.has(newTx.id)) { + merged.push(newTx); + existingIds.add(newTx.id); + } + } + + const sorted = merged.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + + queryClient.setQueryData( + queryKeys.transactions.user(userAddress), + { ...existingData, data: sorted } + ); + + return { + success: true, + syncedCount: newTransactions.length, + hasMore: false, + }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Failed to fetch delta updates'; + return { success: false, error: errorMsg, syncedCount: 0, hasMore: false }; + } + } + + getIsSyncing(): boolean { + return this.isSyncing; + } + + resetSyncState(): void { + this.isSyncing = false; + this.syncQueue = Promise.resolve(); + } +} + +export const transactionSyncService = new TransactionSyncService(); + +export function useTransactionSync() { + return { + syncTransactions: (userAddress: string, options?: Record) => + transactionSyncService.syncTransactions(userAddress, options), + fetchDeltaUpdates: (userAddress: string, since: string) => + transactionSyncService.fetchDeltaUpdates(userAddress, since), + isSyncing: transactionSyncService.getIsSyncing(), + }; +} \ No newline at end of file