diff --git a/mobile/__tests__/utils/formatXLM.test.ts b/mobile/__tests__/utils/formatXLM.test.ts index eb19059..004b9e9 100644 --- a/mobile/__tests__/utils/formatXLM.test.ts +++ b/mobile/__tests__/utils/formatXLM.test.ts @@ -1,31 +1,40 @@ import { formatXLM } from '@/utils/stellar'; +function normalizeSpaces(value: string): string { + return value.replace(/\u00a0|\u202f/g, ' '); +} + describe('formatXLM', () => { it('formats an integer amount with thousand separators and decimals', () => { - expect(formatXLM(1234)).toBe('1,234.00 XLM'); + expect(formatXLM(1234, 2, 'en')).toBe('1,234.00 XLM'); }); it('formats a large amount with multiple separators', () => { - expect(formatXLM(1000000.5)).toBe('1,000,000.50 XLM'); + expect(formatXLM(1000000.5, 2, 'en')).toBe('1,000,000.50 XLM'); }); it('handles zero with fixed decimals', () => { - expect(formatXLM(0)).toBe('0.00 XLM'); + expect(formatXLM(0, 2, 'en')).toBe('0.00 XLM'); }); it('formats a decimal amount rounded to specified decimals', () => { - expect(formatXLM(1.555, 2)).toBe('1.56 XLM'); - expect(formatXLM(1.5, 1)).toBe('1.5 XLM'); + expect(formatXLM(1.555, 2, 'en')).toBe('1.56 XLM'); + expect(formatXLM(1.5, 1, 'en')).toBe('1.5 XLM'); }); it('returns 0.00 XLM for NaN input', () => { // @ts-ignore - testing runtime NaN - expect(formatXLM(NaN)).toBe('0.00 XLM'); + expect(formatXLM(NaN, 2, 'en')).toBe('0.00 XLM'); // @ts-ignore - testing runtime invalid string - expect(formatXLM('abc' as any)).toBe('0.00 XLM'); + expect(formatXLM('abc' as any, 2, 'en')).toBe('0.00 XLM'); }); it('handles negative numbers', () => { - expect(formatXLM(-1234.5)).toBe('-1,234.50 XLM'); + expect(formatXLM(-1234.5, 2, 'en')).toBe('-1,234.50 XLM'); + }); + + it('uses a comma decimal separator for French locale', () => { + const formatted = normalizeSpaces(formatXLM(1234.5, 2, 'fr')); + expect(formatted).toMatch(/^1[ \u00a0\u202f]234,50 XLM$/); }); }); diff --git a/mobile/app/(tabs)/groups/page.tsx b/mobile/app/(tabs)/groups/page.tsx index e4ed95d..f4aed1c 100644 --- a/mobile/app/(tabs)/groups/page.tsx +++ b/mobile/app/(tabs)/groups/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { FlatList, RefreshControl, SafeAreaView, View, Text, Pressable, StyleSheet } from 'react-native'; import { useRouter } from 'expo-router'; -import { Badge, ErrorState, LoadingSkeleton, TextInput } from '../../../components/ui'; +import { Badge, EmptyState, ErrorState, LoadingSkeleton, TextInput } from '../../../components/ui'; import { useDebounce } from '../../../hooks/useDebounce'; import { useRefresh } from '../../../hooks/useRefresh'; import { formatXLM } from '../../../utils/stellar'; @@ -194,11 +194,11 @@ export default function GroupsPage() { ) : error ? ( ) : ( - data={filteredGroups} - keyExtractor={(item) => item.id} + keyExtractor={(item: Group) => item.id} renderItem={renderGroup} - getItemLayout={(_, index) => ({ length: 110, offset: 110 * index, index })} + getItemLayout={(_: unknown, index: number) => ({ length: 110, offset: 110 * index, index })} removeClippedSubviews maxToRenderPerBatch={10} windowSize={5} @@ -211,10 +211,12 @@ export default function GroupsPage() { /> } ListEmptyComponent={ - - No groups to show - Try another filter to see matching groups. - + } /> )} @@ -326,20 +328,4 @@ const styles = StyleSheet.create({ color: '#475569', marginTop: 4, }, - emptyState: { - marginTop: 32, - alignItems: 'center', - }, - emptyTitle: { - fontSize: 18, - fontWeight: '700', - color: '#0F172A', - marginBottom: 8, - }, - emptyMessage: { - fontSize: 15, - color: '#64748B', - textAlign: 'center', - maxWidth: 260, - }, }); diff --git a/mobile/app/referrals/index.tsx b/mobile/app/referrals/index.tsx index 9e767a1..a5fb28f 100644 --- a/mobile/app/referrals/index.tsx +++ b/mobile/app/referrals/index.tsx @@ -11,6 +11,8 @@ import { } from 'react-native'; import * as Clipboard from 'expo-clipboard'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { EmptyState } from '../../components/ui'; +import { formatDate } from '../../utils/formatDate'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -115,11 +117,7 @@ function StatsRow({ stats }: { stats: ReferralStats }) { } function ReferralRow({ item }: { item: Referral }) { - const date = new Date(item.joinedAt).toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + const date = formatDate(item.joinedAt, { month: 'short', day: 'numeric', year: 'numeric' }); return ( @@ -196,7 +194,7 @@ export default function ReferralsScreen({ publicKey }: ReferralsScreenProps) { style={styles.screen} contentContainerStyle={styles.content} data={referrals} - keyExtractor={(item) => item.id} + keyExtractor={(item: Referral) => item.id} ListHeaderComponent={ <> Invite & earn @@ -208,11 +206,14 @@ export default function ReferralsScreen({ publicKey }: ReferralsScreenProps) { {referrals.length > 0 && Your referrals} } - renderItem={({ item }) => } + renderItem={({ item }: { item: Referral }) => } ListEmptyComponent={ - - No referrals yet — share your code to get started! - + } /> ); @@ -289,6 +290,4 @@ const styles = StyleSheet.create({ badgeTextConfirmed: { color: '#065F46' }, badgeTextPending: { color: '#92400E' }, - emptyList: { alignItems: 'center', paddingVertical: 32 }, - emptyText: { fontSize: 14, color: '#94A3B8', textAlign: 'center' }, -}); \ No newline at end of file +}); diff --git a/mobile/app/transaction/history.tsx b/mobile/app/transaction/history.tsx index 7b3540e..0055c04 100644 --- a/mobile/app/transaction/history.tsx +++ b/mobile/app/transaction/history.tsx @@ -11,6 +11,7 @@ import { } from 'react-native'; import { useRouter } from 'expo-router'; import { TransactionItem, TransactionType } from '../../components/transactions/TransactionItem'; +import { EmptyState } from '../../components/ui'; import { useRefresh } from '../../hooks/useRefresh'; type TabKey = 'All' | 'Contributions' | 'Payouts'; @@ -142,9 +143,12 @@ export default function TransactionHistoryScreen() { contentContainerStyle={styles.list} refreshControl={} ListEmptyComponent={ - - No {activeTab.toLowerCase()} to show. - + } /> @@ -190,6 +194,4 @@ const styles = StyleSheet.create({ backgroundColor: '#6366F1', }, list: { paddingHorizontal: 16, paddingTop: 8 }, - empty: { marginTop: 48, alignItems: 'center' }, - emptyText: { color: '#64748B', fontSize: 15 }, }); diff --git a/mobile/app/transactions/index.tsx b/mobile/app/transactions/index.tsx index d446ca9..e241d80 100644 --- a/mobile/app/transactions/index.tsx +++ b/mobile/app/transactions/index.tsx @@ -121,7 +121,8 @@ export default function TransactionHistory() { onEndReachedThreshold={0.3} ListEmptyComponent={ diff --git a/mobile/components/groups/ContributionHistory.tsx b/mobile/components/groups/ContributionHistory.tsx index 18866bc..573e4ff 100644 --- a/mobile/components/groups/ContributionHistory.tsx +++ b/mobile/components/groups/ContributionHistory.tsx @@ -5,6 +5,7 @@ import { View, Text, StyleSheet, FlatList } from 'react-native'; import { SectionHeader } from '../ui/SectionHeader'; import { EmptyState } from '../ui/EmptyState'; import { formatXLM } from '../../utils/stellar'; +import { formatDate } from '../../utils/formatDate'; type Transaction = { contributor: string; @@ -22,11 +23,6 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) return `${address.slice(0, 6)}...${address.slice(-4)}`; }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - }; - const renderTransaction = ({ item }: { item: Transaction }) => ( @@ -38,7 +34,9 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) - {formatDate(item.date)} + + {formatDate(item.date, { month: 'short', day: 'numeric', year: 'numeric' })} + @@ -52,7 +50,8 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) @@ -67,7 +66,7 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps) `${item.contributor}-${item.round}-${index}`} + keyExtractor={(item: Transaction, index: number) => `${item.contributor}-${item.round}-${index}`} showsVerticalScrollIndicator={false} ItemSeparatorComponent={() => } /> diff --git a/mobile/components/groups/PayoutSchedule.tsx b/mobile/components/groups/PayoutSchedule.tsx index 8ff42af..9fa9cd4 100644 --- a/mobile/components/groups/PayoutSchedule.tsx +++ b/mobile/components/groups/PayoutSchedule.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { formatDate } from '../../utils/formatDate'; type RoundStatus = 'upcoming' | 'completed' | 'current'; @@ -22,11 +23,6 @@ export function PayoutSchedule({ rounds }: PayoutScheduleProps) { return `${address.slice(0, 6)}...${address.slice(-4)}`; }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - }; - const getStatusIcon = (status: RoundStatus) => { switch (status) { case 'completed': @@ -94,7 +90,7 @@ export function PayoutSchedule({ rounds }: PayoutScheduleProps) { styles.dateText, round.status === 'current' && styles.currentText ]}> - {formatDate(round.date)} + {formatDate(round.date, { month: 'short', day: 'numeric', year: 'numeric' })} diff --git a/mobile/components/ui/EmptyState.tsx b/mobile/components/ui/EmptyState.tsx index 856d797..9eb38d1 100644 --- a/mobile/components/ui/EmptyState.tsx +++ b/mobile/components/ui/EmptyState.tsx @@ -1,47 +1,2 @@ -import React from 'react'; -import { View, Text, Pressable, StyleSheet } from 'react-native'; -import { getIllustration, type IllustrationType } from './Illustration'; - -interface Props { - icon?: string; - illustration?: IllustrationType; - title: string; - message: string; - actionLabel?: string; - onAction?: () => void; -} - -export function EmptyState({ icon, illustration, title, message, actionLabel, onAction }: Props) { - const selected = illustration ? getIllustration(illustration) : getIllustration('default'); - const useFallbackIcon = Boolean(icon); - - return ( - - - {useFallbackIcon ? {icon} : selected.render()} - - {title} - {message} - {actionLabel && ( - - {actionLabel} - - )} - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, - illustration: { marginBottom: 14 }, - icon: { fontSize: 48 }, - title: { fontSize: 18, fontWeight: '600', color: '#F1F5F9', marginBottom: 8 }, - message: { fontSize: 14, color: '#94A3B8', textAlign: 'center', marginBottom: 20 }, - button: { - backgroundColor: '#334155', - borderRadius: 8, - paddingVertical: 10, - paddingHorizontal: 20, - }, - buttonText: { color: '#F1F5F9', fontWeight: '600', fontSize: 14 }, -}); +export { EmptyState } from './empty'; +export type { EmptyStateIllustration } from './empty'; diff --git a/mobile/components/ui/empty.tsx b/mobile/components/ui/empty.tsx new file mode 100644 index 0000000..61781a4 --- /dev/null +++ b/mobile/components/ui/empty.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import Svg, { Circle, Path, Rect, Ellipse, Line } from 'react-native-svg'; +import { useTheme } from '../../context/ThemeContext'; + +export type EmptyStateIllustration = 'groups' | 'transactions' | 'notifications' | 'default'; + +function GroupsIllustration() { + return ( + + + + + + + + + + ); +} + +function TransactionsIllustration() { + return ( + + + + + + + + + + + + ); +} + +function NotificationsIllustration() { + return ( + + + + + + + ); +} + +function DefaultIllustration() { + return ( + + + + + + + + ); +} + +function Illustration({ type }: { type: EmptyStateIllustration }) { + switch (type) { + case 'groups': + return ; + case 'transactions': + return ; + case 'notifications': + return ; + default: + return ; + } +} + +type Tone = 'light' | 'dark' | 'auto'; + +interface Props { + title: string; + message: string; + illustration?: EmptyStateIllustration; + icon?: string; + actionLabel?: string; + onAction?: () => void; + tone?: Tone; +} + +export function EmptyState({ title, message, illustration, icon, actionLabel, onAction, tone = 'auto' }: Props) { + const { resolvedColorScheme, colors } = useTheme(); + const effectiveTone = tone === 'auto' ? resolvedColorScheme : tone; + const titleColor = effectiveTone === 'dark' ? '#F1F5F9' : '#0F172A'; + const messageColor = effectiveTone === 'dark' ? '#94A3B8' : '#64748B'; + const buttonBg = effectiveTone === 'dark' ? '#334155' : colors.accent; + const buttonText = effectiveTone === 'dark' ? '#F1F5F9' : '#FFFFFF'; + + return ( + + + {illustration ? : icon ? {icon} : } + + {title} + {message} + {actionLabel ? ( + + {actionLabel} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }, + illustration: { marginBottom: 18, opacity: 0.98 }, + icon: { fontSize: 48 }, + title: { fontSize: 18, fontWeight: '700', marginBottom: 8, textAlign: 'center' }, + message: { fontSize: 14, textAlign: 'center', marginBottom: 18, lineHeight: 20 }, + button: { borderRadius: 10, paddingVertical: 10, paddingHorizontal: 18 }, + buttonText: { fontWeight: '700', fontSize: 14 }, +}); + diff --git a/mobile/screens/NotificationsScreen.tsx b/mobile/screens/NotificationsScreen.tsx index 019b6ec..3e3becb 100644 --- a/mobile/screens/NotificationsScreen.tsx +++ b/mobile/screens/NotificationsScreen.tsx @@ -2,6 +2,8 @@ import React, { useEffect } from 'react'; import { View, FlatList } from 'react-native'; import { useNotificationsStore } from '../stores/notificationsStore'; import { NotificationItem } from '../components/NotificationItem'; +import { EmptyState } from '../components/ui'; +import type { Notification } from '../types/notification'; export const NotificationsScreen = () => { const { notifications, setNotifications } = useNotificationsStore(); @@ -32,9 +34,18 @@ export const NotificationsScreen = () => { item.id} - renderItem={({ item }) => } + keyExtractor={(item: Notification) => item.id} + renderItem={({ item }: { item: Notification }) => } + contentContainerStyle={notifications.length === 0 ? { flexGrow: 1 } : undefined} + ListEmptyComponent={ + + } /> ); -}; \ No newline at end of file +}; diff --git a/mobile/services/api/groupsApi.ts b/mobile/services/api/groupsApi.ts index df894ed..1023a52 100644 --- a/mobile/services/api/groupsApi.ts +++ b/mobile/services/api/groupsApi.ts @@ -3,6 +3,8 @@ * Handles REST API calls for group-related operations */ +import { logger } from '../logger'; + export interface Group { id: string; name: string; @@ -37,7 +39,7 @@ class GroupsApiService { */ async getUserGroups(userAddress: string): Promise> { try { - console.log(`Fetching groups for user: ${userAddress}`); + logger.debug('GroupsApi', 'Fetching user groups'); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 1000)); @@ -76,7 +78,7 @@ class GroupsApiService { data: mockGroups, }; } catch (error) { - console.error('Failed to fetch user groups:', error); + logger.error('GroupsApi', 'Failed to fetch user groups', error); return { success: false, error: 'Failed to fetch groups', @@ -89,7 +91,7 @@ class GroupsApiService { */ async getGroupById(groupId: string): Promise> { try { - console.log(`Fetching group details for: ${groupId}`); + logger.debug('GroupsApi', 'Fetching group details', { groupId }); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 800)); @@ -113,7 +115,7 @@ class GroupsApiService { data: mockGroup, }; } catch (error) { - console.error('Failed to fetch group details:', error); + logger.error('GroupsApi', 'Failed to fetch group details', error); return { success: false, error: 'Failed to fetch group details', diff --git a/mobile/services/api/notificationsApi.ts b/mobile/services/api/notificationsApi.ts index 23d608d..e4a9914 100644 --- a/mobile/services/api/notificationsApi.ts +++ b/mobile/services/api/notificationsApi.ts @@ -5,6 +5,7 @@ import { ApiResponse } from './groupsApi'; import { Notification } from '../../types/notification'; +import { logger } from '../logger'; class NotificationsApiService { private baseUrl: string; @@ -18,7 +19,7 @@ class NotificationsApiService { */ async getUserNotifications(userAddress: string): Promise> { try { - console.log(`Fetching notifications for user: ${userAddress}`); + logger.debug('NotificationsApi', 'Fetching user notifications'); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 800)); @@ -45,7 +46,7 @@ class NotificationsApiService { data: mockNotifications, }; } catch (error) { - console.error('Failed to fetch notifications:', error); + logger.error('NotificationsApi', 'Failed to fetch notifications', error); return { success: false, error: 'Failed to fetch notifications', diff --git a/mobile/services/api/transactionsApi.ts b/mobile/services/api/transactionsApi.ts index 6c21d64..0a26b1f 100644 --- a/mobile/services/api/transactionsApi.ts +++ b/mobile/services/api/transactionsApi.ts @@ -4,6 +4,7 @@ */ import { ApiResponse } from './groupsApi'; +import { logger } from '../logger'; export interface Transaction { id: string; @@ -32,7 +33,7 @@ class TransactionsApiService { limit: number = 20 ): Promise> { try { - console.log(`Fetching transactions for user: ${userAddress}`); + logger.debug('TransactionsApi', 'Fetching user transactions'); // Mock API call - replace with actual implementation await new Promise(resolve => setTimeout(resolve, 1000)); @@ -65,7 +66,7 @@ class TransactionsApiService { data: mockTransactions, }; } catch (error) { - console.error('Failed to fetch transactions:', error); + logger.error('TransactionsApi', 'Failed to fetch transactions', error); return { success: false, error: 'Failed to fetch transactions', diff --git a/mobile/services/logger/index.ts b/mobile/services/logger/index.ts index 64172e5..baf9732 100644 --- a/mobile/services/logger/index.ts +++ b/mobile/services/logger/index.ts @@ -1,45 +1,2 @@ -/** - * Standardized logger for the mobile app. - * Non-error logs are disabled in production. - */ - -const IS_DEV = __DEV__; - -type LogLevel = 'debug' | 'info' | 'warn' | 'error'; - -function shouldLog(level: LogLevel): boolean { - return IS_DEV || level === 'error'; -} - -function write(level: LogLevel, tag: string, message: string, ...args: unknown[]) { - if (!shouldLog(level)) return; - - const timestamp = new Date().toISOString(); - const prefix = `[${timestamp}][${level.toUpperCase()}][${tag}]`; - - switch (level) { - case 'debug': - console.debug(prefix, message, ...args); - break; - case 'info': - console.info(prefix, message, ...args); - break; - case 'warn': - console.warn(prefix, message, ...args); - break; - case 'error': - console.error(prefix, message, ...args); - break; - } -} - -export const logger = { - debug: (tag: string, message: string, ...args: unknown[]) => - write('debug', tag, message, ...args), - info: (tag: string, message: string, ...args: unknown[]) => - write('info', tag, message, ...args), - warn: (tag: string, message: string, ...args: unknown[]) => - write('warn', tag, message, ...args), - error: (tag: string, message: string, ...args: unknown[]) => - write('error', tag, message, ...args), -}; +export { logger } from '../services/logger'; +export type { LogLevel } from '../services/logger'; diff --git a/mobile/services/notifications.ts b/mobile/services/notifications.ts index efab2a7..363fe90 100644 --- a/mobile/services/notifications.ts +++ b/mobile/services/notifications.ts @@ -1,6 +1,7 @@ import * as Notifications from 'expo-notifications'; import * as Device from 'expo-device'; import { Platform } from 'react-native'; +import { logger } from './logger'; Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -12,7 +13,7 @@ Notifications.setNotificationHandler({ export async function registerForPushNotificationsAsync(): Promise { if (!Device.isDevice) { - console.warn('[notifications] Push tokens require a physical device'); + logger.warn('Notifications', 'Push tokens require a physical device'); return null; } @@ -23,7 +24,7 @@ export async function registerForPushNotificationsAsync(): Promise Localization.locale ?? 'en'; diff --git a/mobile/utils/logger.ts b/mobile/utils/logger.ts index af06e8a..baf9732 100644 --- a/mobile/utils/logger.ts +++ b/mobile/utils/logger.ts @@ -1 +1,2 @@ export { logger } from '../services/logger'; +export type { LogLevel } from '../services/logger'; diff --git a/mobile/utils/stellar.ts b/mobile/utils/stellar.ts index cd3d13a..21023bf 100644 --- a/mobile/utils/stellar.ts +++ b/mobile/utils/stellar.ts @@ -1,3 +1,7 @@ +import * as Localization from 'expo-localization'; + +const getLocale = (): string => Localization.locale ?? 'en'; + /** * Truncates a Stellar address to the format GXXX...XXXX. * Returns the original string if it is too short to truncate. @@ -13,17 +17,14 @@ export function truncateAddress(address: string, leading = 4, trailing = 4): str * * @param amount The numeric amount to format * @param decimals The number of decimal places (default 2) + * @param locale Optional locale override (defaults to device locale) * @returns A formatted string e.g., "1,234.50 XLM" */ -export function formatXLM(amount: number, decimals = 2): string { - const value = isNaN(amount) || amount === null || amount === undefined ? 0 : amount; - - const sign = value >= 0 ? 1 : -1; - const factor = Math.pow(10, decimals); - const rounded = sign * (Math.round(Math.abs(value) * factor) / factor); - - const parts = rounded.toFixed(decimals).split('.'); - parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); - - return `${parts.join('.')} XLM`; +export function formatXLM(amount: number, decimals = 2, locale?: string): string { + const value = typeof amount === 'number' && Number.isFinite(amount) ? amount : 0; + const formatter = new Intl.NumberFormat(locale ?? getLocale(), { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + return `${formatter.format(value)} XLM`; }