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
74 changes: 74 additions & 0 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
Laptop,
Calendar,
AlertCircle,
Loader,
CheckCircle,
XCircle,
} from 'lucide-react-native';
import { colors } from '../../constants/colors';
import { EmptyState } from '../../components/shared/EmptyState';
Expand All @@ -21,25 +24,25 @@
import { reputationService } from '../../services/reputation.service';
import { ReputationProgressWidget } from '../../components/reputation/ReputationProgressWidget';
import { useTranslation } from '../../hooks/useTranslation';
import { formatCurrency, formatDate } from '../../src/locales/i18n';

Check warning on line 27 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Lint / Typecheck / Test

'formatCurrency' is defined but never used
import type { AvailableCredit } from '../../services/loans.service';

export default function HomeScreen() {
const { t } = useTranslation();
const router = useRouter();
const profile = useUserStore((s) => s.profile);
const reputation = useUserStore((s) => s.reputation);

Check warning on line 34 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Lint / Typecheck / Test

'reputation' is assigned a value but never used
const setReputation = useUserStore((s) => s.setReputation);
const loans = useLoansStore((s) => s.loans);
const setLoans = useLoansStore((s) => s.setLoans);
const walletAddress = useAuthStore((s) => s.walletAddress);

const [credit, setCredit] = useState<AvailableCredit | null>(null);
const [isLoading, setIsLoading] = useState(true);

Check warning on line 41 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Lint / Typecheck / Test

'isLoading' is assigned a value but never used
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);

const displayName = profile?.displayName ?? t('home.greeting');

Check warning on line 45 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Lint / Typecheck / Test

'displayName' is assigned a value but never used

const fetchDashboard = useCallback(async () => {
setError(null);
Expand All @@ -59,7 +62,7 @@
setIsLoading(false);
setIsRefreshing(false);
}
}, [walletAddress, setLoans, setReputation]);

Check warning on line 65 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Lint / Typecheck / Test

React Hook useCallback has a missing dependency: 't'. Either include it or remove the dependency array

useEffect(() => {
void fetchDashboard();
Expand All @@ -70,8 +73,18 @@
void fetchDashboard();
};

const pendingTransactions = useLoansStore((s) => s.pendingTransactions);
const setPendingTransactions = useLoansStore((s) => s.setPendingTransactions);

// Sync pending txs from the persisted queue on mount
useEffect(() => {
import('../../src/transactions/pending-queue').then(({ pendingQueue }) => {
pendingQueue.getAll().then(setPendingTransactions);
});
}, [setPendingTransactions]);

const activeLoans = loans.filter((l) => l.status === 'active');
const nextInstallment = activeLoans

Check warning on line 87 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Lint / Typecheck / Test

'nextInstallment' is assigned a value but never used
.flatMap((l) => l.installments.filter((i) => !i.paid).map((i) => ({ ...i, loanId: l.id })))
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime())[0];

Expand Down Expand Up @@ -191,6 +204,67 @@
</View>
</View>

{/* Pending Transactions Banner */}
{pendingTransactions.filter((tx) => tx.status === 'pending').length > 0 && (
<View
className="mb-4 flex-row items-center gap-3 rounded-xl border p-4"
style={{
backgroundColor: colors.warningDim,
borderColor: colors.warning + '40',
}}>
<View
className="h-10 w-10 items-center justify-center rounded-xl"
style={{ backgroundColor: colors.warning + '20' }}>
<Loader size={20} color={colors.warning} />
</View>
<View className="flex-1">
<Text className="text-sm font-semibold" style={{ color: colors.warning }}>
Transaction In Progress
</Text>
<Text className="mt-0.5 text-xs" style={{ color: colors.textSecondary }}>
{pendingTransactions.filter((tx) => tx.status === 'pending').length} pending
transaction(s) — tracking on-chain
</Text>
</View>
</View>
)}

{/* Transaction Result Banner */}
{pendingTransactions.filter(
(tx) => tx.status === 'confirmed' || tx.status === 'failed' || tx.status === 'expired'
).length > 0 && (
<View className="mb-4 gap-2 rounded-xl p-3" style={{ backgroundColor: colors.subtle }}>
<Text className="mb-1 text-xs font-semibold" style={{ color: colors.textMuted }}>
Recent Transactions
</Text>
{pendingTransactions
.filter((tx) => tx.status !== 'pending')
.slice(0, 5)
.map((tx) => {
const isConfirmed = tx.status === 'confirmed';
const isFailed = tx.status === 'failed' || tx.status === 'expired';

Check warning on line 245 in app/(tabs)/index.tsx

View workflow job for this annotation

GitHub Actions / Lint / Typecheck / Test

'isFailed' is assigned a value but never used
const StatusIcon = isConfirmed ? CheckCircle : XCircle;
const statusColor = isConfirmed ? colors.success : colors.error;
return (
<View key={tx.id} className="flex-row items-center gap-2 rounded-lg py-2">
<StatusIcon size={14} color={statusColor} />
<Text className="flex-1 text-xs" style={{ color: colors.textSecondary }}>
{tx.type === 'REPAYMENT'
? 'Repayment'
: tx.type === 'LOAN_CREATION'
? 'Loan Creation'
: tx.type}{' '}
— ${tx.amount?.toLocaleString() ?? ''}
</Text>
<Text className="text-[10px]" style={{ color: statusColor }}>
{isConfirmed ? 'Confirmed' : 'Failed'}
</Text>
</View>
);
})}
</View>
)}

{/* Quick Actions Grid */}
<View className="mb-8 flex-row justify-between">
{[
Expand Down
122 changes: 112 additions & 10 deletions app/(tabs)/loans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
CheckCircle,
Clock,
XCircle,
ChevronRight,
Loader,
RefreshCw,
} from 'lucide-react-native';
import { colors } from '../../constants/colors';
import { Card } from '../../components/shared/Card';
Expand All @@ -16,7 +17,9 @@ import { useLoansStore } from '../../stores/loans.store';
import { loansService } from '../../services/loans.service';
import { useTranslation } from '../../hooks/useTranslation';
import { formatDate } from '../../src/locales/i18n';
import { pendingQueue } from '../../src/transactions/pending-queue';
import type { Loan, LoanStatus } from '../../types/loan.types';
import type { PendingTransaction } from '../../types/transaction.types';

function getStatusConfig(
status: LoanStatus,
Expand Down Expand Up @@ -68,6 +71,27 @@ function getStatusConfig(
}
}

/** Small badge showing if a loan has a pending on-chain tx. */
function PendingTxBadge({ loanId }: { loanId: string }) {
const pendingTransactions = useLoansStore((s) => s.pendingTransactions);
const loanPendingTxs = pendingTransactions.filter(
(tx) => tx.targetLoanId === loanId && tx.status === 'pending'
);

if (loanPendingTxs.length === 0) return null;

return (
<View
className="flex-row items-center gap-1 rounded-lg px-2 py-0.5"
style={{ backgroundColor: colors.warningDim }}>
<Loader size={10} color={colors.warning} />
<Text className="text-[10px] font-semibold" style={{ color: colors.warning }}>
{loanPendingTxs.length} pending
</Text>
</View>
);
}

interface LoanCardProps {
loan: Loan;
t: (key: string, opts?: any) => string;
Expand Down Expand Up @@ -101,14 +125,17 @@ function LoanCard({ loan, t }: LoanCardProps) {
</View>
</View>

{/* Status badge */}
<View
className="flex-row items-center gap-1 rounded-xl px-3 py-1"
style={{ backgroundColor: statusConfig.bg }}>
<StatusIcon size={12} color={statusConfig.color} />
<Text className="text-xs font-semibold" style={{ color: statusConfig.color }}>
{statusConfig.label}
</Text>
{/* Status badge + pending indicator */}
<View className="flex-row items-center gap-2">
<PendingTxBadge loanId={loan.id} />
<View
className="flex-row items-center gap-1 rounded-xl px-3 py-1"
style={{ backgroundColor: statusConfig.bg }}>
<StatusIcon size={12} color={statusConfig.color} />
<Text className="text-xs font-semibold" style={{ color: statusConfig.color }}>
{statusConfig.label}
</Text>
</View>
</View>
</View>

Expand Down Expand Up @@ -160,27 +187,42 @@ export default function LoansScreen() {
const { t } = useTranslation();
const loans = useLoansStore((s) => s.loans);
const setLoans = useLoansStore((s) => s.setLoans);
const pendingTransactions = useLoansStore((s) => s.pendingTransactions);
const setPendingTransactions = useLoansStore((s) => s.setPendingTransactions);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);

// Sync pending txs from the persisted queue on mount
const syncPendingTxs = useCallback(async () => {
const pendings = await pendingQueue.getAll();
setPendingTransactions(pendings);
}, [setPendingTransactions]);

const fetchLoans = useCallback(async () => {
setError(null);
try {
const data = await loansService.getMyLoans();
setLoans(data);
await syncPendingTxs();
} catch {
setError(t('loans.errorLoading'));
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [setLoans]);
}, [setLoans, syncPendingTxs]);

useEffect(() => {
void fetchLoans();
}, [fetchLoans]);

// Re-sync pending txs periodically and whenever local count changes
useEffect(() => {
const interval = setInterval(syncPendingTxs, 5000);
return () => clearInterval(interval);
}, [syncPendingTxs]);

const handleRefresh = () => {
setIsRefreshing(true);
void fetchLoans();
Expand Down Expand Up @@ -222,6 +264,12 @@ export default function LoansScreen() {
);
}

const pendingCount = pendingTransactions.filter((tx) => tx.status === 'pending').length;
const recentCompleted = pendingTransactions
.filter((tx) => tx.status !== 'pending')
.sort((a, b) => b.updatedAt - a.updatedAt)
.slice(0, 5);

return (
<SafeAreaView className="flex-1" style={{ backgroundColor: colors.background }}>
<ScrollView
Expand All @@ -238,6 +286,60 @@ export default function LoansScreen() {
{t('loans.myLoans')}
</Text>

{/* Pending Transactions Banner */}
{pendingCount > 0 && (
<View
className="mb-4 flex-row items-center gap-3 rounded-xl border p-4"
style={{
backgroundColor: colors.warningDim,
borderColor: colors.warning + '40',
}}>
<View
className="h-10 w-10 items-center justify-center rounded-xl"
style={{ backgroundColor: colors.warning + '20' }}>
<Loader size={20} color={colors.warning} />
</View>
<View className="flex-1">
<Text className="text-sm font-semibold" style={{ color: colors.warning }}>
Transactions In Progress
</Text>
<Text className="mt-0.5 text-xs" style={{ color: colors.textSecondary }}>
{pendingCount} pending transaction(s) — status being tracked on-chain
</Text>
</View>
</View>
)}

{/* Recent Completed Transactions */}
{recentCompleted.length > 0 && (
<View className="mb-4 gap-1.5 rounded-xl p-3" style={{ backgroundColor: colors.subtle }}>
<Text className="mb-1 text-xs font-semibold" style={{ color: colors.textMuted }}>
Recent Transaction Activity
</Text>
{recentCompleted.map((tx) => {
const isConfirmed = tx.status === 'confirmed';
const StatusIcon = isConfirmed ? CheckCircle : XCircle;
const statusColor = isConfirmed ? colors.success : colors.error;
return (
<View key={tx.id} className="flex-row items-center gap-2 py-1.5">
<StatusIcon size={14} color={statusColor} />
<Text className="flex-1 text-xs" style={{ color: colors.textSecondary }}>
{tx.type === 'REPAYMENT'
? 'Repayment'
: tx.type === 'LOAN_CREATION'
? 'Loan'
: tx.type}
{tx.amount ? ` — $${tx.amount.toLocaleString()}` : ''}
</Text>
<Text className="text-[10px]" style={{ color: statusColor }}>
{isConfirmed ? 'Confirmed' : tx.status === 'expired' ? 'Expired' : 'Failed'}
</Text>
</View>
);
})}
</View>
)}

{/* Summary */}
<View className="mb-5 flex-row gap-3">
<View
Expand Down
31 changes: 31 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ import {
addBreadcrumb,
Sentry,
} from '../services/sentry';
import {
startTxPoller,
stopTxPoller,
reconcilePendingTxs,
} from '../src/transactions/transaction-poller';
import { pendingQueue } from '../src/transactions/pending-queue';
import { useLoansStore } from '../stores/loans.store';
import '../global.css';

// Initialise Sentry as early as possible (module‑level, before any component)
Expand Down Expand Up @@ -139,6 +146,11 @@ function RootLayout() {
if (!useSecurityStore.getState().isLocked) {
startIdleTimer();
}

// Reconcile any pending transactions when the app comes to foreground
if (useAuthStore.getState().isAuthenticated) {
reconcilePendingTxs().catch(() => {});
}
} else if (state === 'background' || state === 'inactive') {
lastActiveRef.current = Date.now();
clearIdleTimer();
Expand All @@ -154,6 +166,25 @@ function RootLayout() {
}
}, [isLocked, isAuthenticated, startIdleTimer]);

// ─── Transaction poller lifecycle ─────────────────────────────────────
useEffect(() => {
if (!isLoading && isAuthenticated) {
startTxPoller();
}
return () => {
stopTxPoller();
};
}, [isLoading, isAuthenticated]);

// ─── Pending‑tx sync on store hydration ───────────────────────────────
useEffect(() => {
if (!isLoading && isAuthenticated) {
pendingQueue.getAll().then((all) => {
useLoansStore.getState().setPendingTransactions(all);
});
}
}, [isLoading, isAuthenticated]);

const prevConnectedRef = useRef(true);

useEffect(() => {
Expand Down
19 changes: 18 additions & 1 deletion hooks/useRepayment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useCallback } from 'react';
import { loansService } from '../services/loans.service';
import { useTransaction } from './useTransaction';
import { pendingQueue } from '../src/transactions/pending-queue';
import { useLoansStore } from '../stores/loans.store';
import type { UseTransactionReturn } from './useTransaction';

export interface UseRepaymentReturn extends UseTransactionReturn {
Expand All @@ -16,7 +18,22 @@ export function useRepayment(): UseRepaymentReturn {

const { unsignedXdr } = await loansService.repayInstallment(loanId, installmentIndex, amount);

await execute(unsignedXdr);
const result = await execute(unsignedXdr);

// On successful broadcast, persist the pending tx for background tracking
if (result?.txHash) {
const entry = await pendingQueue.add({
txHash: result.txHash,
type: 'REPAYMENT',
targetLoanId: loanId,
targetInstallmentIndex: installmentIndex,
amount,
});

// Sync into the loans store for live UI updates
const all = await pendingQueue.getAll();
useLoansStore.getState().setPendingTransactions(all);
}
},
[status, execute]
);
Expand Down
Loading
Loading