diff --git a/mobileapp/__tests__/TransferConfirmationCard.test.tsx b/mobileapp/__tests__/TransferConfirmationCard.test.tsx index 676883f2..1e0560e4 100644 --- a/mobileapp/__tests__/TransferConfirmationCard.test.tsx +++ b/mobileapp/__tests__/TransferConfirmationCard.test.tsx @@ -148,4 +148,38 @@ describe("TransferConfirmationCard", () => { expect(getByText("Privacy")).toBeTruthy(); expect(getByText("FRIENDS")).toBeTruthy(); }); + + it("renders real name and username parameters when real name is provided", () => { + const { getByText, getByTestId } = render( + + ); + + expect(getByTestId("confirmation-real-name")).toBeTruthy(); + expect(getByText("Tolu Adebayo")).toBeTruthy(); + expect(getByText("tolu.zaps")).toBeTruthy(); + }); + + it("displays verification badge for trusted profiles", () => { + const { getByLabelText } = render( + + ); + + expect(getByLabelText("Verified recipient")).toBeTruthy(); + }); }); diff --git a/mobileapp/__tests__/api.test.ts b/mobileapp/__tests__/api.test.ts index 0beac847..aa577531 100644 --- a/mobileapp/__tests__/api.test.ts +++ b/mobileapp/__tests__/api.test.ts @@ -3,6 +3,10 @@ import { completePrivySignup, linkPrivyAddress, stellarAddressFromPrivyPublicKey, + getRecentRecipients, + saveRecentRecipient, + getCachedRecentRecipientsSync, + clearRecentRecipientsCache, } from "../src/services/api"; jest.mock("@react-native-async-storage/async-storage", () => ({ @@ -181,3 +185,48 @@ describe("Privy signup API service", () => { expect(mockAsyncStorage.setItem).not.toHaveBeenCalled(); }); }); + +describe("Recent recipient local cache helper (Issue #914)", () => { + beforeEach(async () => { + jest.clearAllMocks(); + await clearRecentRecipientsCache(); + }); + + it("stores and fetches recent recipients from local persistent storage", async () => { + await saveRecentRecipient("alice.zaps"); + await saveRecentRecipient("bob.zaps"); + + const cached = await getRecentRecipients(); + expect(cached).toEqual(["bob.zaps", "alice.zaps"]); + expect(mockAsyncStorage.setItem).toHaveBeenCalledWith( + "recent_recipient_usernames", + JSON.stringify(["bob.zaps", "alice.zaps"]) + ); + }); + + it("fetches from cache instantly without waiting for network", () => { + saveRecentRecipient("instant.zaps"); + const syncResult = getCachedRecentRecipientsSync(); + expect(syncResult).toContain("instant.zaps"); + }); + + it("limits cached contacts to 20 maximum", async () => { + for (let i = 1; i <= 25; i++) { + await saveRecentRecipient(`user${i}.zaps`); + } + + const cached = await getRecentRecipients(); + expect(cached.length).toBe(20); + expect(cached[0]).toBe("user25.zaps"); + expect(cached).not.toContain("user1.zaps"); + }); + + it("deduplicates and moves re-used recipient to the top", async () => { + await saveRecentRecipient("user1.zaps"); + await saveRecentRecipient("user2.zaps"); + await saveRecentRecipient("user1.zaps"); + + const cached = await getRecentRecipients(); + expect(cached).toEqual(["user1.zaps", "user2.zaps"]); + }); +}); diff --git a/mobileapp/__tests__/transactions.test.tsx b/mobileapp/__tests__/transactions.test.tsx new file mode 100644 index 00000000..b073d725 --- /dev/null +++ b/mobileapp/__tests__/transactions.test.tsx @@ -0,0 +1,126 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react-native"; +import TransactionsScreen from "../app/(personal)/transactions"; +import type { Transaction } from "../src/types/transaction"; + +jest.mock("@react-native-async-storage/async-storage", () => + require("@react-native-async-storage/async-storage/jest/async-storage-mock") +); + +jest.mock("expo-router", () => ({ + useRouter: jest.fn(() => ({ + push: jest.fn(), + back: jest.fn(), + })), +})); + +jest.mock("../src/services/stellarWallet", () => ({ + getLocalKeypair: jest.fn(() => null), + checkFreighter: jest.fn(() => Promise.resolve(false)), + speedUpTransaction: jest.fn(() => Promise.resolve()), +})); + +jest.mock("../src/components/TransactionFilterSheet", () => ({ + TransactionFilterSheet: () => null, +})); + +const mockTransactions: Transaction[] = [ + { + id: "tx-sdp-1", + type: "mass_payout", + status: "completed", + amount: "100.00", + asset: "USDC", + fiatValue: "150,000", + fiatCurrency: "NGN", + address: "GABC1234567890DEF1234567890ABCDEF1234567890ABCDEF1234567890AB", + addressLabel: "Relief Payout", + timestamp: new Date().toISOString(), + senderName: "Mercy Relief Org", + senderAnchor: "Anchor Global", + }, + { + id: "tx-standard-1", + type: "sent", + status: "completed", + amount: "25.00", + asset: "XLM", + fiatValue: "5,000", + fiatCurrency: "NGN", + address: "GDEF1234567890DEF1234567890ABCDEF1234567890ABCDEF1234567890CD", + addressLabel: "Tolu", + timestamp: new Date().toISOString(), + }, + { + id: "tx-standard-2", + type: "received", + status: "completed", + amount: "50.00", + asset: "USDC", + fiatValue: "75,000", + fiatCurrency: "NGN", + address: "GXYZ1234567890DEF1234567890ABCDEF1234567890ABCDEF1234567890EF", + addressLabel: "Amina", + timestamp: new Date().toISOString(), + }, +]; + +const mockSetFilters = jest.fn(); +const mockRefresh = jest.fn(); +const mockLoadMore = jest.fn(); + +jest.mock("../src/hooks/useTransactions", () => ({ + useTransactions: () => ({ + transactions: mockTransactions, + loading: false, + refreshing: false, + loadingMore: false, + filters: { type: "all", status: "all", search: "" }, + setFilters: mockSetFilters, + refresh: mockRefresh, + loadMore: mockLoadMore, + }), +})); + +describe("TransactionsScreen Disbursement History (Issue #921)", () => { + it("renders distinct section grouping for Received Mass Payouts separate from standard transfers", () => { + const { getByTestId, getByText } = render(); + + expect(getByTestId("mass-payout-section-header")).toBeTruthy(); + expect(getByText("Received Mass Payouts (SDP)")).toBeTruthy(); + expect(getByText("Standard Transfers")).toBeTruthy(); + }); + + it("renders unique badge and label for SDP disbursements", () => { + const { getByTestId, getByText } = render(); + + expect(getByTestId("sdp-disbursement-badge")).toBeTruthy(); + expect(getByText("SDP Disbursement")).toBeTruthy(); + }); + + it("renders sender company and anchor information for mass payouts", () => { + const { getByText, getByTestId } = render(); + + expect(getByText("Mercy Relief Org")).toBeTruthy(); + expect(getByTestId("sender-anchor-info")).toBeTruthy(); + expect(getByText("Anchor: Anchor Global")).toBeTruthy(); + }); + + it("applies conditional list item rendering based on transaction type", () => { + const { getByTestId } = render(); + + expect(getByTestId("tx-row-tx-sdp-1")).toBeTruthy(); + expect(getByTestId("tx-row-tx-standard-1")).toBeTruthy(); + expect(getByTestId("tx-row-tx-standard-2")).toBeTruthy(); + }); + + it("switches view when SDP Disbursements tab is selected", () => { + const { getByTestId, queryByText } = render(); + + const sdpTab = getByTestId("mass-payout-tab"); + fireEvent.press(sdpTab); + + expect(getByTestId("mass-payout-section-header")).toBeTruthy(); + expect(queryByText("Standard Transfers")).toBeNull(); + }); +}); diff --git a/mobileapp/app/(personal)/history.tsx b/mobileapp/app/(personal)/history.tsx index d8aa373b..715871ca 100644 --- a/mobileapp/app/(personal)/history.tsx +++ b/mobileapp/app/(personal)/history.tsx @@ -183,16 +183,16 @@ const TransactionRow = React.memo(function TransactionRow({ {isMassPayout && item.senderName ? item.senderName : label} {isMassPayout && ( - - - Mass Payout + + + SDP Disbursement )} {/* Anchor/sender info for SDP disbursements */} - {isMassPayout && item.senderAnchor && ( - - via {item.senderAnchor} + {isMassPayout && ( + + {item.senderAnchor ? `via ${item.senderAnchor}` : "Stellar Disbursement Platform"} )} diff --git a/mobileapp/app/(personal)/home.tsx b/mobileapp/app/(personal)/home.tsx index 80fdc6bf..67a47907 100644 --- a/mobileapp/app/(personal)/home.tsx +++ b/mobileapp/app/(personal)/home.tsx @@ -1027,14 +1027,22 @@ export default function HomeScreen() { - - Auto-Earn on Idle Funds - + + Auto-Earn on Idle Funds + + + { + if (val) { + openAutoEarnDescModal(); + } else { + void handleAutoYieldToggle(false); + } + }} disabled={autoEarnSyncing} trackColor={{ false: "#E2E8F0", true: "#34D399" }} thumbColor={COLORS.white} @@ -1059,6 +1073,19 @@ export default function HomeScreen() { accessibilityRole="switch" /> + {autoEarnTooltipVisible && ( + + + + Auto-Earn automatically sweeps idle funds into yield-earning vaults at competitive APYs while remaining instantly spendable. + + + )} {autoEarnSyncing && ( diff --git a/mobileapp/app/(personal)/transactions.tsx b/mobileapp/app/(personal)/transactions.tsx index 832480f6..a52a0c99 100644 --- a/mobileapp/app/(personal)/transactions.tsx +++ b/mobileapp/app/(personal)/transactions.tsx @@ -1,13 +1,1041 @@ -/** - * transactions.tsx - * - * Full transaction list screen that supports section grouping, - * distinguishing SDP "Mass Payout" disbursements from standard transfers. - * - * Renders the same HistoryScreen used in the tab navigator, available as - * a standalone route for deep-link and programmatic navigation use-cases. - * - * Issue: #576 — Design transaction list section grouping - * Received Mass Payouts distinct from standard transfers. - */ -export { default } from "./history"; +import React, { useEffect, useState, useCallback, useMemo } from "react"; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + TextInput, + ActivityIndicator, + RefreshControl, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { Ionicons, Feather } from "@expo/vector-icons"; +import { useRouter } from "expo-router"; +import { COLORS } from "../../src/constants/colors"; +import { useTransactions } from "../../src/hooks/useTransactions"; +import { TransactionFilterSheet } from "../../src/components/TransactionFilterSheet"; +import { Transaction, TransactionFilters } from "../../src/types/transaction"; +import { formatDate } from "../../src/utils/formatting"; +import { + speedUpTransaction, + getLocalKeypair, + checkFreighter, +} from "../../src/services/stellarWallet"; + +// Time threshold to consider a pending transaction stuck (1 minute) +const STUCK_PENDING_MS = 60 * 1000; + +// Yield transaction history filtering presets +const DATE_PRESETS: { label: string; days: number }[] = [ + { label: "7D", days: 7 }, + { label: "30D", days: 30 }, + { label: "90D", days: 90 }, + { label: "1Y", days: 365 }, +]; + +function presetStartIso(days: number): string { + const d = new Date(); + d.setDate(d.getDate() - days); + d.setHours(0, 0, 0, 0); + return d.toISOString().slice(0, 10); +} + +function isPresetActive(filters: TransactionFilters, days: number): boolean { + return filters.dateFrom === presetStartIso(days) && !filters.dateTo; +} + +function canSpeedUp(tx: Transaction): boolean { + if (tx.status !== "pending" || !tx.stellarTxXdr) return false; + return Date.now() - new Date(tx.timestamp).getTime() > STUCK_PENDING_MS; +} + +async function resolveWalletSource(): Promise< + "freighter" | "albedo" | "local" +> { + const kp = await getLocalKeypair(); + if (kp) return "local"; + if (await checkFreighter()) return "freighter"; + return "local"; +} + +function statusColor(status: Transaction["status"]): string { + switch (status) { + case "completed": + return "#22C55E"; + case "pending": + return "#F59E0B"; + case "failed": + return "#EF4444"; + } +} + +function typeIcon(type: Transaction["type"]) { + switch (type) { + case "sent": + return { name: "arrow-up" as const, bg: "#FFEBEE", color: "#EF4444" }; + case "received": + return { name: "arrow-down" as const, bg: "#E8F5E9", color: "#22C55E" }; + case "yield": + return { name: "trending-up" as const, bg: "#F0FDF4", color: "#16A34A" }; + case "mass_payout": + return { + name: "people" as const, + bg: "#EDE9FE", + color: "#7C3AED", + }; + default: + return { + name: "swap-horizontal" as const, + bg: "#E3F2FD", + color: "#2196F3", + }; + } +} + +function yieldTagLabel(yieldType: Transaction["yieldType"]): string { + switch (yieldType) { + case "interest": + return "Interest"; + case "reward": + return "Reward"; + case "apy": + return "APY"; + default: + return "Yield"; + } +} + +function truncateAddress(addr: string): string { + if (!addr) return ""; + if (addr.length <= 20) return addr; + return `${addr.slice(0, 8)}…${addr.slice(-6)}`; +} + +function hasActiveFilters(f: TransactionFilters): boolean { + return ( + f.type !== "all" || + f.status !== "all" || + Boolean(f.dateFrom) || + Boolean(f.dateTo) || + Boolean(f.amountMin) || + Boolean(f.amountMax) || + Boolean(f.yieldOnly) || + Boolean(f.asset) + ); +} + +// Transaction Row with conditional rendering logic based on transaction type +const TransactionRow = React.memo(function TransactionRow({ + item, + onPress, + onSpeedUp, + speedingUp, +}: { + item: Transaction; + onPress: () => void; + onSpeedUp: (tx: Transaction) => void; + speedingUp: boolean; +}) { + const isMassPayout = item.type === "mass_payout"; + const isOutgoing = item.type === "sent"; + const isYield = item.type === "yield"; + const icon = typeIcon(item.type); + const time = formatDate(item.timestamp, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + const showSpeedUp = canSpeedUp(item); + + return ( + + {/* Icon with conditional styling */} + + + + + {/* Info Column */} + + {/* Label row with conditional SDP disbursement badge */} + + + {isMassPayout + ? item.senderName || item.addressLabel || "Disbursing Organization" + : item.addressLabel ?? truncateAddress(item.address)} + + + {isMassPayout && ( + + + SDP Disbursement + + )} + + + {/* Sender information: company and anchor name for SDP mass payouts */} + {isMassPayout && ( + + + + {item.senderAnchor + ? `Anchor: ${item.senderAnchor}` + : "Stellar Disbursement Platform"} + + + )} + + + {time} + + {isYield && ( + + + + {yieldTagLabel(item.yieldType)} + + + )} + + {item.status !== "completed" && ( + + + {item.status} + + + )} + + + {showSpeedUp && ( + onSpeedUp(item)} + disabled={speedingUp} + activeOpacity={0.8} + accessibilityRole="button" + accessibilityLabel={`Speed up ${item.amount} ${item.asset}`} + > + {speedingUp ? ( + + ) : ( + <> + + Speed up + + )} + + )} + + + {/* Amount Column */} + + + {isOutgoing ? "−" : "+"} + {item.amount} {item.asset} + + + {item.fiatValue} {item.fiatCurrency} + + + + ); +}); + +// Section Header components +function SectionHeader({ title }: { title: string }) { + return ( + + {title} + + ); +} + +function MassPayoutSectionHeader({ count }: { count?: number }) { + return ( + + + + + + + Received Mass Payouts (SDP) + + + {count !== undefined && count > 0 && ( + + {count} + + )} + + ); +} + +// Group transactions with distinct section for Received Mass Payouts +type ListItem = + | { kind: "mass_payout_header"; key: string; count: number } + | { kind: "section_header"; key: string; title: string } + | { kind: "date_header"; key: string; title: string } + | { kind: "tx"; key: string; tx: Transaction }; + +function groupTransactions( + txs: Transaction[], + sectionFilter: "all" | "mass_payout" | "transfers" +): ListItem[] { + const result: ListItem[] = []; + + const safeTxs = Array.isArray(txs) ? txs : []; + const massPayouts = safeTxs.filter((t) => t.type === "mass_payout"); + const standardTransfers = safeTxs.filter((t) => t.type !== "mass_payout"); + + // If filtering to mass payouts only + if (sectionFilter === "mass_payout") { + if (massPayouts.length > 0) { + result.push({ + kind: "mass_payout_header", + key: "mp_main_header", + count: massPayouts.length, + }); + for (const tx of massPayouts) { + result.push({ kind: "tx", key: tx.id, tx }); + } + } + return result; + } + + // If filtering to standard transfers only + if (sectionFilter === "transfers") { + let lastDate = ""; + for (const tx of standardTransfers) { + const d = new Date(tx.timestamp); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + let label: string; + if (d.toDateString() === today.toDateString()) { + label = "Today"; + } else if (d.toDateString() === yesterday.toDateString()) { + label = "Yesterday"; + } else { + label = formatDate(tx.timestamp, { + month: "long", + day: "numeric", + year: "numeric", + }); + } + + if (label !== lastDate) { + lastDate = label; + result.push({ kind: "date_header", key: `hdr_${label}`, title: label }); + } + result.push({ kind: "tx", key: tx.id, tx }); + } + return result; + } + + // Default "all" mode: Group Received Mass Payouts in a distinct prominent section, + // followed by Standard Transfers grouped by date. + if (massPayouts.length > 0) { + result.push({ + kind: "mass_payout_header", + key: "mp_main_header", + count: massPayouts.length, + }); + for (const tx of massPayouts) { + result.push({ kind: "tx", key: tx.id, tx }); + } + } + + if (standardTransfers.length > 0) { + result.push({ + kind: "section_header", + key: "sec_standard_transfers", + title: "Standard Transfers", + }); + + let lastDate = ""; + for (const tx of standardTransfers) { + const d = new Date(tx.timestamp); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + let label: string; + if (d.toDateString() === today.toDateString()) { + label = "Today"; + } else if (d.toDateString() === yesterday.toDateString()) { + label = "Yesterday"; + } else { + label = formatDate(tx.timestamp, { + month: "long", + day: "numeric", + year: "numeric", + }); + } + + if (label !== lastDate) { + lastDate = label; + result.push({ kind: "date_header", key: `hdr_${label}`, title: label }); + } + result.push({ kind: "tx", key: tx.id, tx }); + } + } + + return result; +} + +// Full transactions screen with Disbursement history section +export default function TransactionsScreen() { + const router = useRouter(); + const { + transactions, + loading, + refreshing, + loadingMore, + filters, + setFilters, + refresh, + loadMore, + } = useTransactions(); + + const [filterVisible, setFilterVisible] = useState(false); + const [speedingUpId, setSpeedingUpId] = useState(null); + const [speedUpError, setSpeedUpError] = useState(null); + const [sectionFilter, setSectionFilter] = useState< + "all" | "mass_payout" | "transfers" + >("all"); + const [searchText, setSearchText] = useState(filters.search); + + // Sync search input with hook filters + const handleSearch = (text: string) => { + setSearchText(text); + setFilters((prev) => ({ ...prev, search: text })); + }; + + const handleSpeedUp = useCallback(async (tx: Transaction) => { + if (!tx.stellarTxXdr) return; + setSpeedingUpId(tx.id); + setSpeedUpError(null); + try { + const source = await resolveWalletSource(); + await speedUpTransaction(tx.stellarTxXdr, source); + await refresh(); + } catch (err) { + setSpeedUpError( + err instanceof Error ? err.message : "Failed to speed up transaction" + ); + } finally { + setSpeedingUpId(null); + } + }, [refresh]); + + const items = useMemo( + () => groupTransactions(transactions, sectionFilter), + [transactions, sectionFilter] + ); + + const filtersActive = hasActiveFilters(filters); + + const renderItem = useCallback( + ({ item }: { item: ListItem }) => { + if (item.kind === "mass_payout_header") { + return ; + } + if (item.kind === "section_header" || item.kind === "date_header") { + return ; + } + return ( + router.push(`/transaction/${item.tx.id}` as any)} + onSpeedUp={handleSpeedUp} + speedingUp={speedingUpId === item.tx.id} + /> + ); + }, + [router, handleSpeedUp, speedingUpId] + ); + + const ListFooter = () => { + if (!loadingMore) return null; + return ( + + + + ); + }; + + const ListEmpty = () => { + if (loading) return null; + return ( + + + No transactions + + {filtersActive || searchText + ? "Try adjusting your filters or search." + : "Your transactions and mass payouts will appear here."} + + + ); + }; + + return ( + + {/* Header */} + + Transactions & Disbursements + setFilterVisible(true)} + accessibilityLabel="Open filters" + > + + {filtersActive && } + + + + {/* Search Bar */} + + + + + {searchText.length > 0 && ( + handleSearch("")} + style={styles.clearBtn} + > + + + )} + + + + {/* Section Switcher Tabs */} + + setSectionFilter("all")} + accessibilityRole="button" + > + + All Activity + + + + setSectionFilter("mass_payout")} + accessibilityRole="button" + testID="mass-payout-tab" + > + + + SDP Disbursements + + + + setSectionFilter("transfers")} + accessibilityRole="button" + > + + Transfers + + + + + {/* Date Presets Row */} + + {DATE_PRESETS.map(({ label, days }) => { + const active = isPresetActive(filters, days); + return ( + { + setFilters((prev) => ({ + ...prev, + dateFrom: active ? undefined : presetStartIso(days), + dateTo: undefined, + })); + }} + accessibilityLabel={`Filter by last ${label}`} + > + + {label} + + + ); + })} + + + {/* Speed up error banner */} + {Boolean(speedUpError) && ( + + + {speedUpError} + setSpeedUpError(null)}> + + + + )} + + {/* Main List */} + item.key} + ListFooterComponent={ListFooter} + ListEmptyComponent={ListEmpty} + refreshControl={ + + } + onEndReached={loadMore} + onEndReachedThreshold={0.3} + contentContainerStyle={[ + styles.listContent, + items.length === 0 && styles.listContentEmpty, + ]} + /> + + {/* Filter Sheet */} + setFilterVisible(false)} + filters={filters} + onApply={(applied) => setFilters(applied)} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#F9FAFB", + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 20, + paddingVertical: 14, + backgroundColor: COLORS.white, + borderBottomWidth: 1, + borderBottomColor: "#F3F4F6", + }, + headerTitle: { + fontSize: 20, + fontFamily: "Outfit_700Bold", + color: COLORS.black, + }, + filterBtn: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: "#F3F4F6", + justifyContent: "center", + alignItems: "center", + }, + filterBtnActive: { + backgroundColor: COLORS.primary, + }, + filterDot: { + position: "absolute", + top: 8, + right: 8, + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: "#22C55E", + }, + searchRow: { + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: COLORS.white, + }, + searchBox: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#F3F4F6", + borderRadius: 12, + paddingHorizontal: 12, + height: 42, + }, + searchIcon: { + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: 14, + fontFamily: "Outfit_400Regular", + color: COLORS.black, + }, + clearBtn: { + padding: 4, + }, + sectionTabsRow: { + flexDirection: "row", + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: COLORS.white, + gap: 8, + borderBottomWidth: 1, + borderBottomColor: "#F3F4F6", + }, + sectionTabBtn: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 20, + backgroundColor: "#F3F4F6", + }, + sectionTabBtnActive: { + backgroundColor: COLORS.primary, + }, + sectionTabText: { + fontSize: 13, + fontFamily: "Outfit_500Medium", + color: "#6B7280", + }, + sectionTabTextActive: { + color: COLORS.secondary, + fontFamily: "Outfit_600SemiBold", + }, + presetsRow: { + flexDirection: "row", + paddingHorizontal: 16, + paddingVertical: 8, + gap: 8, + backgroundColor: COLORS.white, + borderBottomWidth: 1, + borderBottomColor: "#F3F4F6", + }, + presetBtn: { + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 16, + backgroundColor: "#F3F4F6", + }, + presetBtnActive: { + backgroundColor: COLORS.secondary, + }, + presetBtnText: { + fontSize: 12, + fontFamily: "Outfit_500Medium", + color: "#6B7280", + }, + presetBtnTextActive: { + color: COLORS.primary, + fontFamily: "Outfit_600SemiBold", + }, + listContent: { + paddingBottom: 40, + }, + listContentEmpty: { + flexGrow: 1, + }, + txRow: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 14, + backgroundColor: COLORS.white, + borderBottomWidth: 1, + borderBottomColor: "#F3F4F6", + }, + txRowMassPayout: { + backgroundColor: "#FAFAFE", + }, + txIcon: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: "center", + alignItems: "center", + marginRight: 12, + }, + txInfo: { + flex: 1, + marginRight: 8, + }, + txLabelRow: { + flexDirection: "row", + alignItems: "center", + flexWrap: "wrap", + gap: 6, + }, + txLabel: { + fontSize: 15, + fontFamily: "Outfit_600SemiBold", + color: COLORS.black, + maxWidth: "70%", + }, + sdpBadge: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#EDE9FE", + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 10, + gap: 3, + }, + sdpBadgeText: { + fontSize: 11, + fontFamily: "Outfit_600SemiBold", + color: "#7C3AED", + }, + senderAnchorRow: { + flexDirection: "row", + alignItems: "center", + gap: 4, + marginTop: 2, + }, + senderAnchorText: { + fontSize: 12, + fontFamily: "Outfit_400Regular", + color: "#6B7280", + }, + txMeta: { + flexDirection: "row", + alignItems: "center", + gap: 6, + marginTop: 4, + }, + txTime: { + fontSize: 12, + fontFamily: "Outfit_400Regular", + color: "#9CA3AF", + }, + yieldTag: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#F0FDF4", + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 6, + gap: 2, + }, + yieldTagText: { + fontSize: 11, + fontFamily: "Outfit_500Medium", + color: "#16A34A", + }, + statusPill: { + paddingHorizontal: 6, + paddingVertical: 1, + borderRadius: 6, + }, + statusPillText: { + fontSize: 11, + fontFamily: "Outfit_500Medium", + textTransform: "capitalize", + }, + speedUpBtn: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + backgroundColor: COLORS.primary, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 8, + marginTop: 6, + }, + speedUpBtnText: { + fontSize: 12, + fontFamily: "Outfit_500Medium", + color: COLORS.white, + }, + txAmount: { + alignItems: "flex-end", + }, + txAmountText: { + fontSize: 15, + fontFamily: "Outfit_700Bold", + }, + txFiat: { + fontSize: 12, + fontFamily: "Outfit_400Regular", + color: "#9CA3AF", + marginTop: 2, + }, + sectionHeader: { + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: "#F9FAFB", + }, + sectionHeaderText: { + fontSize: 12, + fontFamily: "Outfit_600SemiBold", + color: "#6B7280", + textTransform: "uppercase", + letterSpacing: 0.5, + }, + massPayoutSectionHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + paddingVertical: 12, + backgroundColor: "#F5F3FF", + borderLeftWidth: 4, + borderLeftColor: "#7C3AED", + }, + massPayoutHeaderLeft: { + flexDirection: "row", + alignItems: "center", + gap: 8, + }, + massPayoutHeaderIconWrap: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: "#EDE9FE", + justifyContent: "center", + alignItems: "center", + }, + massPayoutSectionHeaderText: { + fontSize: 13, + fontFamily: "Outfit_700Bold", + color: "#7C3AED", + }, + massPayoutCountBadge: { + backgroundColor: "#7C3AED", + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + }, + massPayoutCountText: { + fontSize: 11, + fontFamily: "Outfit_600SemiBold", + color: COLORS.white, + }, + speedUpErrorBanner: { + flexDirection: "row", + alignItems: "center", + backgroundColor: "#FEE2E2", + paddingHorizontal: 16, + paddingVertical: 8, + gap: 8, + }, + speedUpErrorText: { + flex: 1, + fontSize: 12, + fontFamily: "Outfit_400Regular", + color: "#EF4444", + }, + footerLoader: { + paddingVertical: 20, + alignItems: "center", + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingTop: 80, + paddingHorizontal: 40, + }, + emptyTitle: { + fontSize: 17, + fontFamily: "Outfit_600SemiBold", + color: "#374151", + marginTop: 16, + }, + emptySubtext: { + fontSize: 13, + fontFamily: "Outfit_400Regular", + color: "#9CA3AF", + textAlign: "center", + marginTop: 6, + }, +}); diff --git a/mobileapp/app/transfer.tsx b/mobileapp/app/transfer.tsx index 3bd96d08..1416a9a8 100644 --- a/mobileapp/app/transfer.tsx +++ b/mobileapp/app/transfer.tsx @@ -44,7 +44,11 @@ import { StellarWalletState, type TransactionFeeEstimate, } from "../src/services/stellarWallet"; -import { getRecentRecipients, saveRecentRecipient } from "../src/services/api"; +import { + getCachedRecentRecipientsSync, + getRecentRecipients, + saveRecentRecipient, +} from "../src/services/api"; import { isFederatedAddress } from "../src/utils/sep0007"; // #580 — Biometric Verification Overlay import { authenticateWithBiometrics } from "./biometric"; @@ -316,7 +320,9 @@ function TransferScreen() { // Recipient search state const [searchResults, setSearchResults] = useState([]); - const [recentRecipients, setRecentRecipients] = useState([]); + const [recentRecipients, setRecentRecipients] = useState(() => + getCachedRecentRecipientsSync() + ); const [searching, setSearching] = useState(false); const [showDropdown, setShowDropdown] = useState(false); const [selectedUser, setSelectedUser] = useState(null); @@ -399,12 +405,27 @@ function TransferScreen() { setUsernameStatus("idle"); return; } - const body: { username: string; address: string; is_blacklisted?: boolean } = - await res.json(); + const body: { + username: string; + address: string; + is_blacklisted?: boolean; + display_name?: string; + name?: string; + avatar_url?: string; + is_verified?: boolean; + } = await res.json(); if (body.is_blacklisted) { setUsernameStatus("blacklisted"); } else { setUsernameStatus("registered"); + setSelectedUser((prev: ZapsUser | null) => ({ + username: body.username, + address: body.address, + avatar_url: body.avatar_url ?? prev?.avatar_url ?? null, + name: body.display_name ?? body.name ?? prev?.name ?? null, + display_name: body.display_name ?? prev?.display_name ?? null, + isVerified: body.is_verified ?? prev?.isVerified ?? false, + })); } } catch { // Network error — don't block the user from proceeding @@ -524,6 +545,8 @@ function TransferScreen() { setRecipient(contact.username); setSelectedUser({ username: contact.username, + name: contact.contactName, + display_name: contact.contactName, address: contact.address, avatar_url: contact.avatarUrl ?? null, isVerified: false, @@ -658,6 +681,63 @@ function TransferScreen() { [] ); + const processConfirmedTransfer = async () => { + // Biometric Verification Overlay + // Gate the payment submission behind biometric / passcode authentication. + // The transfer is blocked unless the user successfully authenticates. + const biometricResult = await authenticateWithBiometrics( + "Verify your identity to confirm the transfer" + ); + + if (!biometricResult.success) { + if (biometricResult.reason === "cancelled") { + return; + } + Alert.alert( + "Authentication Required", + "You must verify your identity before sending a payment. Please try again.", + [{ text: "OK" }] + ); + return; + } + + setSubmitting(true); + try { + const assetIssuer = + token.symbol !== "XLM" + ? STELLAR_ASSET_ISSUERS[token.symbol] + : undefined; + const result = await submitPayment( + recipient, + amount, + walletState!.source!, + walletState!.publicKey, + token.symbol, + assetIssuer, + description || undefined + ); + const stored = await AsyncStorage.getItem("pending_transfers"); + const list = stored ? JSON.parse(stored) : []; + list.unshift({ + recipient, + amount, + description: description || "Sent money", + visibility, + token: token.symbol, + hash: result.hash, + }); + await AsyncStorage.setItem("pending_transfers", JSON.stringify(list)); + await saveRecentRecipient(recipient); + } catch (e) { + Alert.alert("Transfer Failed", (e as Error).message); + setSubmitting(false); + return; + } + setSubmitting(false); + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setStep(4); + }; + const handleNext = async () => { if (step === 0 && transferType === "BATCH") { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); @@ -676,61 +756,27 @@ function TransferScreen() { } if (step === 3) { - // #580 — Biometric Verification Overlay - // Gate the payment submission behind biometric / passcode authentication. - // The transfer is blocked unless the user successfully authenticates. - const biometricResult = await authenticateWithBiometrics( - "Verify your identity to confirm the transfer" + const recipientDisplayName = + selectedUser?.name || + selectedUser?.display_name || + selectedUser?.real_name; + const recipientLabel = recipientDisplayName + ? `${recipientDisplayName} (@${recipient})` + : recipient; + + Alert.alert( + "Confirm Transfer", + `Are you sure you want to send ${amount} ${token.symbol} to ${recipientLabel}?`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Confirm", + onPress: () => { + void processConfirmedTransfer(); + }, + }, + ] ); - - if (!biometricResult.success) { - if (biometricResult.reason === "cancelled") { - // User deliberately dismissed the prompt — do nothing, stay on step 3. - return; - } - Alert.alert( - "Authentication Required", - "You must verify your identity before sending a payment. Please try again.", - [{ text: "OK" }] - ); - return; - } - - setSubmitting(true); - try { - const assetIssuer = - token.symbol !== "XLM" - ? STELLAR_ASSET_ISSUERS[token.symbol] - : undefined; - const result = await submitPayment( - recipient, - amount, - walletState!.source!, - walletState!.publicKey, - token.symbol, - assetIssuer, - description || undefined - ); - const stored = await AsyncStorage.getItem("pending_transfers"); - const list = stored ? JSON.parse(stored) : []; - list.unshift({ - recipient, - amount, - description: description || "Sent money", - visibility, - token: token.symbol, - hash: result.hash, - }); - await AsyncStorage.setItem("pending_transfers", JSON.stringify(list)); - await saveRecentRecipient(recipient); - } catch (e) { - Alert.alert("Transfer Failed", (e as Error).message); - setSubmitting(false); - return; - } - setSubmitting(false); - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - setStep(4); return; } diff --git a/mobileapp/src/components/TransferConfirmationCard.tsx b/mobileapp/src/components/TransferConfirmationCard.tsx index 889dfef6..e9e98e5e 100644 --- a/mobileapp/src/components/TransferConfirmationCard.tsx +++ b/mobileapp/src/components/TransferConfirmationCard.tsx @@ -37,13 +37,35 @@ export const TransferConfirmationCard = React.memo( return `${Number.isFinite(numeric) ? numeric.toLocaleString() : "0"} stroops`; }; + const realName = + recipient.name || recipient.display_name || recipient.real_name; + return ( - + + + + {Boolean(realName) && ( + + {realName} + + )} - - {recipient.username} - {recipient.isVerified && } + + + {recipient.username} + + {recipient.isVerified && } + {recipient.address && ( @@ -155,16 +177,31 @@ const styles = StyleSheet.create({ borderColor: "#F0F0F0", alignItems: "center", }, + recipientHeader: { + alignItems: "center", + marginTop: 14, + }, + realName: { + fontSize: 20, + fontFamily: "Outfit_700Bold", + color: COLORS.black, + textAlign: "center", + }, nameRow: { flexDirection: "row", alignItems: "center", - marginTop: 16, + marginTop: 4, }, username: { fontSize: 20, fontFamily: "Outfit_700Bold", color: COLORS.black, }, + usernameSubtitle: { + fontSize: 14, + fontFamily: "Outfit_500Medium", + color: "#666", + }, address: { fontSize: 13, fontFamily: "Outfit_400Regular", diff --git a/mobileapp/src/services/api.ts b/mobileapp/src/services/api.ts index ac9e98a8..3b180cba 100644 --- a/mobileapp/src/services/api.ts +++ b/mobileapp/src/services/api.ts @@ -149,37 +149,66 @@ export async function apiFetch( return response; } -// ── Recent recipients (unchanged; still uses AsyncStorage for non-sensitive data) ── - +// Local cache for frequently paid usernames with AsyncStorage persistence const RECENT_RECIPIENTS_KEY = "recent_recipient_usernames"; const MAX_RECENT_RECIPIENTS = 20; +let inMemoryRecentRecipients: string[] | null = null; + +export function getCachedRecentRecipientsSync(): string[] { + return inMemoryRecentRecipients ? [...inMemoryRecentRecipients] : []; +} + export async function getRecentRecipients(): Promise { + if (inMemoryRecentRecipients !== null) { + return [...inMemoryRecentRecipients]; + } try { const raw = await AsyncStorage.getItem(RECENT_RECIPIENTS_KEY); - return raw ? (JSON.parse(raw) as string[]) : []; + const parsed = raw ? (JSON.parse(raw) as string[]) : []; + inMemoryRecentRecipients = Array.isArray(parsed) + ? parsed.slice(0, MAX_RECENT_RECIPIENTS) + : []; + return [...inMemoryRecentRecipients]; } catch { return []; } } export async function saveRecentRecipient(username: string): Promise { - if (!username) return; + const trimmed = username ? username.trim() : ""; + if (!trimmed) return; try { - const raw = await AsyncStorage.getItem(RECENT_RECIPIENTS_KEY); - const current = raw ? (JSON.parse(raw) as string[]) : []; + let current = inMemoryRecentRecipients; + if (current === null) { + const raw = await AsyncStorage.getItem(RECENT_RECIPIENTS_KEY); + const parsed = raw ? (JSON.parse(raw) as string[]) : []; + current = Array.isArray(parsed) ? parsed : []; + } const next = [ - username, - ...current.filter((item) => item !== username), + trimmed, + ...current.filter((item) => item.toLowerCase() !== trimmed.toLowerCase()), ].slice(0, MAX_RECENT_RECIPIENTS); + inMemoryRecentRecipients = next; await AsyncStorage.setItem(RECENT_RECIPIENTS_KEY, JSON.stringify(next)); } catch { - // Ignore cache failures. + // Ignore cache failures } } -// ── Yield API ───────────────────────────────────────────────────────────────── +export const cacheRecentRecipient = saveRecentRecipient; +export const getFrequentlyPaidUsernames = getRecentRecipients; +export async function clearRecentRecipientsCache(): Promise { + inMemoryRecentRecipients = []; + try { + await AsyncStorage.removeItem(RECENT_RECIPIENTS_KEY); + } catch { + // Ignore cache failures + } +} + +// Yield API export interface YieldBalance { apy: string | number; totalYieldEarned: string | number; @@ -197,12 +226,18 @@ export async function fetchYieldBalance(): Promise { export async function updateAutoEarn(enabled: boolean): Promise { try { - await apiFetch(`${API_BASE}/api/yield/auto-earn`, { - method: "PATCH", + const res = await apiFetch(`${API_BASE}/api/yield/toggle-auto`, { + method: "POST", body: JSON.stringify({ enabled }), }); + if (!res.ok) { + await apiFetch(`${API_BASE}/api/yield/auto-earn`, { + method: "PATCH", + body: JSON.stringify({ enabled }), + }).catch(() => {}); + } } catch { - // Non-fatal — local state already reflects the toggle + // Non-fatal: local state already reflects the toggle } } diff --git a/mobileapp/src/types/user.ts b/mobileapp/src/types/user.ts index e04c5156..aa152de6 100644 --- a/mobileapp/src/types/user.ts +++ b/mobileapp/src/types/user.ts @@ -2,6 +2,10 @@ export interface ZapsUser { username: string; address: string; avatar_url: string | null; - /** Placeholder — real trust data needs to be wired in by the API layer. */ + name?: string | null; + display_name?: string | null; + real_name?: string | null; + /** Placeholder: real trust data needs to be wired in by the API layer. */ isVerified?: boolean; } +