Skip to content
Open
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
76 changes: 75 additions & 1 deletion app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import {
GraduationCap,
Laptop,
Calendar,
AlertCircle
AlertCircle,
Loader,
CheckCircle,
XCircle,
} from 'lucide-react-native';
import { colors } from '../../constants/colors';
import { EmptyState } from '../../components/shared/EmptyState';
Expand Down Expand Up @@ -70,6 +73,16 @@ export default function HomeScreen() {
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
.flatMap((l) => l.installments.filter((i) => !i.paid).map((i) => ({ ...i, loanId: l.id })))
Expand Down Expand Up @@ -166,6 +179,67 @@ export default function HomeScreen() {
</View>
</View>

{/* Pending Transactions Banner */}
{pendingTransactions.filter((tx) => tx.status === 'pending').length > 0 && (
<View
className="rounded-xl p-4 mb-4 flex-row items-center gap-3 border"
style={{
backgroundColor: colors.warningDim,
borderColor: colors.warning + '40',
}}
>
<View
className="h-10 w-10 rounded-xl items-center justify-center"
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="text-xs mt-0.5" 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="rounded-xl p-3 mb-4 gap-2"
style={{ backgroundColor: colors.subtle }}
>
<Text className="text-xs font-semibold mb-1" 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';
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="text-xs flex-1" 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="flex-row justify-between mb-8">
{[
Expand Down
133 changes: 120 additions & 13 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, t: (key: string, opts?: any) => string): {
label: string;
Expand All @@ -40,6 +43,28 @@ function getStatusConfig(status: LoanStatus, t: (key: string, opts?: any) => str
}
}

/** 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 @@ -77,18 +102,21 @@ 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 }}
{/* 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 }}
>
{statusConfig.label}
</Text>
<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 @@ -149,27 +177,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 @@ -205,6 +248,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 @@ -225,6 +274,64 @@ export default function LoansScreen() {
{t('loans.myLoans')}
</Text>

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

{/* Recent Completed Transactions */}
{recentCompleted.length > 0 && (
<View
className="rounded-xl p-3 mb-4 gap-1.5"
style={{ backgroundColor: colors.subtle }}
>
<Text className="text-xs font-semibold mb-1" 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="text-xs flex-1" 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="flex-row gap-3 mb-5">
<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 @@ -132,6 +139,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 @@ -147,6 +159,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 @@ -20,7 +22,22 @@ export function useRepayment(): UseRepaymentReturn {
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