diff --git a/frontend/src/components/RecentTips.jsx b/frontend/src/components/RecentTips.jsx index 464ec53d..b3efbb77 100644 --- a/frontend/src/components/RecentTips.jsx +++ b/frontend/src/components/RecentTips.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { openContractCall } from '@stacks/connect'; import { uintCV, stringUtf8CV, PostConditionMode, Pc } from '@stacks/transactions'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; @@ -8,6 +8,7 @@ import { useTipContext } from '../context/TipContext'; import CopyButton from './ui/copy-button'; const API_BASE = 'https://api.hiro.so'; +const PAGE_SIZE = 10; export default function RecentTips({ addToast }) { const { refreshCounter } = useTipContext(); @@ -19,13 +20,20 @@ export default function RecentTips({ addToast }) { const [tipBackMessage, setTipBackMessage] = useState(''); const [sending, setSending] = useState(false); const [lastRefresh, setLastRefresh] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [minAmount, setMinAmount] = useState(''); + const [maxAmount, setMaxAmount] = useState(''); + const [sortBy, setSortBy] = useState('newest'); + const [showFilters, setShowFilters] = useState(false); + const [offset, setOffset] = useState(0); + const [totalResults, setTotalResults] = useState(0); const fetchRecentTips = useCallback(async () => { try { setError(null); const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`; const response = await fetch( - `${API_BASE}/extended/v1/contract/${contractId}/events?limit=10&offset=0` + `${API_BASE}/extended/v1/contract/${contractId}/events?limit=50&offset=0` ); if (!response.ok) { @@ -42,6 +50,7 @@ export default function RecentTips({ addToast }) { .filter(t => t !== null && t.event === 'tip-sent'); setTips(tipEvents); + setTotalResults(tipEvents.length); setLoading(false); setLastRefresh(new Date()); } catch (err) { @@ -133,6 +142,59 @@ export default function RecentTips({ addToast }) { } }; + const filteredTips = useMemo(() => { + let result = [...tips]; + + if (searchQuery.trim()) { + const query = searchQuery.trim().toLowerCase(); + result = result.filter(tip => { + const sender = (typeof tip.sender === 'string' ? tip.sender : '').toLowerCase(); + const recipient = (typeof tip.recipient === 'string' ? tip.recipient : '').toLowerCase(); + const message = (tip.message || '').toLowerCase(); + return sender.includes(query) || recipient.includes(query) || message.includes(query); + }); + } + + if (minAmount) { + const minMicro = toMicroSTX(minAmount); + result = result.filter(tip => parseInt(tip.amount) >= minMicro); + } + + if (maxAmount) { + const maxMicro = toMicroSTX(maxAmount); + result = result.filter(tip => parseInt(tip.amount) <= maxMicro); + } + + if (sortBy === 'newest') { + // already sorted by newest from API + } else if (sortBy === 'oldest') { + result.reverse(); + } else if (sortBy === 'amount-high') { + result.sort((a, b) => parseInt(b.amount) - parseInt(a.amount)); + } else if (sortBy === 'amount-low') { + result.sort((a, b) => parseInt(a.amount) - parseInt(b.amount)); + } + + return result; + }, [tips, searchQuery, minAmount, maxAmount, sortBy]); + + const paginatedTips = useMemo(() => { + return filteredTips.slice(offset, offset + PAGE_SIZE); + }, [filteredTips, offset]); + + const totalPages = Math.max(1, Math.ceil(filteredTips.length / PAGE_SIZE)); + const currentPage = Math.floor(offset / PAGE_SIZE) + 1; + + const clearFilters = () => { + setSearchQuery(''); + setMinAmount(''); + setMaxAmount(''); + setSortBy('newest'); + setOffset(0); + }; + + const hasActiveFilters = searchQuery || minAmount || maxAmount || sortBy !== 'newest'; + if (loading) { return (
@@ -179,13 +241,94 @@ export default function RecentTips({ addToast }) {
- {tips.length === 0 ? ( +
+
+
+ + + + { setSearchQuery(e.target.value); setOffset(0); }} + className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none" + placeholder="Search by address or message..." + /> +
+ + {hasActiveFilters && ( + + )} +
+ + {showFilters && ( +
+
+ + { setMinAmount(e.target.value); setOffset(0); }} + className="w-24 px-3 py-1.5 border border-gray-200 rounded-lg text-sm outline-none focus:ring-2 focus:ring-gray-900" + placeholder="0" + step="0.001" + min="0" + /> +
+
+ + { setMaxAmount(e.target.value); setOffset(0); }} + className="w-24 px-3 py-1.5 border border-gray-200 rounded-lg text-sm outline-none focus:ring-2 focus:ring-gray-900" + placeholder="any" + step="0.001" + min="0" + /> +
+
+ + +
+
+ )} + + {hasActiveFilters && ( +

+ Showing {filteredTips.length} of {tips.length} tips +

+ )} +
+ + {paginatedTips.length === 0 ? (
-

No tips in the stream yet. Be the first!

+

+ {hasActiveFilters ? 'No tips match your filters' : 'No tips in the stream yet. Be the first!'} +

) : (
- {tips.map((tip, index) => ( + {paginatedTips.map((tip, index) => (
@@ -230,6 +373,28 @@ export default function RecentTips({ addToast }) {
)} + {filteredTips.length > PAGE_SIZE && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} + {tipBackTarget && (