Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions mobile/__tests__/utils/formatXLM.test.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
});
34 changes: 10 additions & 24 deletions mobile/app/(tabs)/groups/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -194,11 +194,11 @@ export default function GroupsPage() {
) : error ? (
<ErrorState message={error} onRetry={fetchGroups} />
) : (
<FlatList
<FlatList<Group>
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}
Expand All @@ -211,10 +211,12 @@ export default function GroupsPage() {
/>
}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyTitle}>No groups to show</Text>
<Text style={styles.emptyMessage}>Try another filter to see matching groups.</Text>
</View>
<EmptyState
tone="light"
illustration="groups"
title="No groups to show"
message="Try another filter or adjust your search to see matching groups."
/>
}
/>
)}
Expand Down Expand Up @@ -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,
},
});
25 changes: 12 additions & 13 deletions mobile/app/referrals/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 (
<View style={styles.referralRow}>
<View style={styles.avatar}>
Expand Down Expand Up @@ -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={
<>
<Text style={styles.heading}>Invite & earn</Text>
Expand All @@ -208,11 +206,14 @@ export default function ReferralsScreen({ publicKey }: ReferralsScreenProps) {
{referrals.length > 0 && <Text style={styles.sectionTitle}>Your referrals</Text>}
</>
}
renderItem={({ item }) => <ReferralRow item={item} />}
renderItem={({ item }: { item: Referral }) => <ReferralRow item={item} />}
ListEmptyComponent={
<View style={styles.emptyList}>
<Text style={styles.emptyText}>No referrals yet — share your code to get started!</Text>
</View>
<EmptyState
tone="light"
illustration="default"
title="No referrals yet"
message="Share your code to get started!"
/>
}
/>
);
Expand Down Expand Up @@ -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' },
});
});
12 changes: 7 additions & 5 deletions mobile/app/transaction/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -142,9 +143,12 @@ export default function TransactionHistoryScreen() {
contentContainerStyle={styles.list}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor="#6366F1" />}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>No {activeTab.toLowerCase()} to show.</Text>
</View>
<EmptyState
tone="dark"
illustration="transactions"
title={activeTab === 'All' ? 'No transactions yet' : `No ${activeTab.toLowerCase()} yet`}
message="Transactions will appear here once you have activity."
/>
}
/>
</SafeAreaView>
Expand Down Expand Up @@ -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 },
});
3 changes: 2 additions & 1 deletion mobile/app/transactions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ export default function TransactionHistory() {
onEndReachedThreshold={0.3}
ListEmptyComponent={
<EmptyState
icon="📭"
tone="dark"
illustration="transactions"
title="No transactions yet"
message="Your contributions and payouts will appear here."
/>
Expand Down
15 changes: 7 additions & 8 deletions mobile/components/groups/ContributionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 }) => (
<View style={styles.transactionRow}>
<View style={styles.contributorColumn}>
Expand All @@ -38,7 +34,9 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps)
</View>

<View style={styles.dateColumn}>
<Text style={styles.dateText}>{formatDate(item.date)}</Text>
<Text style={styles.dateText}>
{formatDate(item.date, { month: 'short', day: 'numeric', year: 'numeric' })}
</Text>
</View>

<View style={styles.roundColumn}>
Expand All @@ -52,7 +50,8 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps)
<View style={styles.container}>
<SectionHeader title="Contribution History" />
<EmptyState
icon="💰"
tone="dark"
illustration="transactions"
title="No contributions yet"
message="Contributions will appear here once members start contributing to the group."
/>
Expand All @@ -67,7 +66,7 @@ export function ContributionHistory({ transactions }: ContributionHistoryProps)
<FlatList
data={transactions}
renderItem={renderTransaction}
keyExtractor={(item, index) => `${item.contributor}-${item.round}-${index}`}
keyExtractor={(item: Transaction, index: number) => `${item.contributor}-${item.round}-${index}`}
showsVerticalScrollIndicator={false}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
Expand Down
8 changes: 2 additions & 6 deletions mobile/components/groups/PayoutSchedule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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':
Expand Down Expand Up @@ -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' })}
</Text>
</View>

Expand Down
49 changes: 2 additions & 47 deletions mobile/components/ui/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.container}>
<View style={styles.illustration}>
{useFallbackIcon ? <Text style={styles.icon}>{icon}</Text> : selected.render()}
</View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
{actionLabel && (
<Pressable onPress={onAction} style={styles.button}>
<Text style={styles.buttonText}>{actionLabel}</Text>
</Pressable>
)}
</View>
);
}

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';
Loading