From 09f49fd453b52484acb3a25f70f4972184b9e022 Mon Sep 17 00:00:00 2001 From: oladosu paul Date: Thu, 18 Jun 2026 06:40:46 +0100 Subject: [PATCH] feat(transactions): add filters, debounced search, and date grouping Adds status/type/date-range filters, debounced recipient/hash search, and Today/Yesterday/Earlier date grouping with WidgetEmptyState no-results state. - Dashboard page: full rewrite with debounced search, direction/status/date filters, date grouping, WidgetEmptyState empty/no-results, aria-live announcements, CSV export - Standalone transactions page: debounced search, date-range filter, Yesterday grouping, i18n via useClientTranslator, WidgetEmptyState replaces StateCard - WidgetEmptyState: extended to support button actions (onAction) alongside links - API: returns userAddress for direction detection - i18n: ~30 new keys in en.json and es.json for filters, date groups, empty states - New useDebounce hook (300ms) --- app/api/v1/remittance/history/route.ts | 11 +- app/dashboard/transaction-history/page.tsx | 698 +++++++++++++++++---- app/transactions/page.tsx | 290 +++++---- components/ui/WidgetEmptyState.tsx | 81 ++- lib/hooks/useDebounce.ts | 18 + lib/i18n/locales/en.json | 66 ++ lib/i18n/locales/es.json | 66 ++ 7 files changed, 961 insertions(+), 269 deletions(-) create mode 100644 lib/hooks/useDebounce.ts diff --git a/app/api/v1/remittance/history/route.ts b/app/api/v1/remittance/history/route.ts index e3e0b81..a7d4c7e 100644 --- a/app/api/v1/remittance/history/route.ts +++ b/app/api/v1/remittance/history/route.ts @@ -5,10 +5,10 @@ import { fetchTransactionHistory } from '../../../../../lib/remittance/horizon'; export const dynamic = 'force-dynamic'; /** - * GET /api/remittance/history (protected) + * GET /api/v1/remittance/history (protected) * Returns list of transactions for session user. - * - * Query params: + * + * Query params: * - limit: number (default 10, max 200) * - cursor: string (pagination) * - status: 'completed' | 'failed' | 'pending' @@ -35,7 +35,10 @@ export async function GET(req: NextRequest) { status: status || undefined, }); - return NextResponse.json(history); + return NextResponse.json({ + ...history, + userAddress: session.address, + }); } catch (error: any) { console.error('Error fetching remittance history:', error); return NextResponse.json( diff --git a/app/dashboard/transaction-history/page.tsx b/app/dashboard/transaction-history/page.tsx index 6b261af..e385fad 100644 --- a/app/dashboard/transaction-history/page.tsx +++ b/app/dashboard/transaction-history/page.tsx @@ -1,209 +1,623 @@ "use client"; -import { useState, useEffect, useCallback } from 'react'; -import TransactionHistoryItem from '@/components/Dashboard/TransactionHistoryItem'; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { Download, FilterIcon, Loader2, SearchX, Inbox, X } from "lucide-react"; +import TransactionHistoryItem from "@/components/Dashboard/TransactionHistoryItem"; import TransactionHistoryHeader from "./components/transaction-history-header"; import TransactionHistorySearchInput from "./components/transaction-history-search-input"; import Button from "./components/transaction-history-button"; -import { Download, FilterIcon, Loader2 } from "lucide-react"; -import { TransactionItem } from '@/lib/remittance/horizon'; -import { useClientTranslator } from '@/lib/i18n/client'; +import WidgetEmptyState from "@/components/ui/WidgetEmptyState"; +import { TransactionItem } from "@/lib/remittance/horizon"; +import { useClientTranslator } from "@/lib/i18n/client"; +import { useDebounce } from "@/lib/hooks/useDebounce"; +import type { Transaction, TransactionStatus } from "@/components/Dashboard/TransactionHistoryItem"; + +type Direction = "all" | "sent" | "received"; + +type GroupKey = "today" | "yesterday" | "earlier"; + +function startOfDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function getGroupKey(date: Date, todayStart: Date, yesterdayStart: Date): GroupKey { + const d = startOfDay(date); + if (d.getTime() === todayStart.getTime()) return "today"; + if (d.getTime() === yesterdayStart.getTime()) return "yesterday"; + return "earlier"; +} const TransactionHistoryPage = () => { const { t } = useClientTranslator(); const [transactions, setTransactions] = useState([]); + const [userAddress, setUserAddress] = useState(null); const [loading, setLoading] = useState(true); + const [initialLoading, setInitialLoading] = useState(true); const [error, setError] = useState(null); - const [searchTerm, setSearchTerm] = useState(''); - const [statusFilter, setStatusFilter] = useState<'all' | 'completed' | 'failed' | 'pending'>('all'); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearch = useDebounce(searchTerm, 300); + const [statusFilter, setStatusFilter] = useState<"all" | "completed" | "failed" | "pending">("all"); + const [directionFilter, setDirectionFilter] = useState("all"); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); const [cursor, setCursor] = useState(); const [hasMore, setHasMore] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); - const fetchTransactions = useCallback(async (currentCursor?: string, reset = false) => { - try { - setLoading(true); - setError(null); - - const params = new URLSearchParams(); - params.append('limit', '10'); - if (currentCursor && !reset) { - params.append('cursor', currentCursor); - } - if (statusFilter !== 'all') { - params.append('status', statusFilter); - } + const todayStart = useMemo(() => startOfDay(new Date()), []); + const yesterdayStart = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() - 1); + return startOfDay(d); + }, []); - const response = await fetch(`/api/v1/remittance/history?${params}`); - if (!response.ok) { - throw new Error(t('transactionHistory.alerts.fetchFailed')); - } + const groupLabels: Record = useMemo( + () => ({ + today: { + label: t("transactionHistory.dateGroups.today"), + helper: t("transactionHistory.dateGroups.todayHelper"), + }, + yesterday: { + label: t("transactionHistory.dateGroups.yesterday"), + helper: t("transactionHistory.dateGroups.yesterdayHelper"), + }, + earlier: { + label: t("transactionHistory.dateGroups.earlier"), + helper: t("transactionHistory.dateGroups.earlierHelper"), + }, + }), + [t] + ); + + const fetchTransactions = useCallback( + async (currentCursor?: string, reset = false) => { + try { + if (reset) { + setLoading(true); + } else { + setLoadingMore(true); + } + setError(null); - const data = await response.json(); - - if (reset) { - setTransactions(data.transactions || []); - } else { - setTransactions(prev => [...prev, ...(data.transactions || [])]); + const params = new URLSearchParams(); + params.append("limit", "50"); + if (currentCursor && !reset) { + params.append("cursor", currentCursor); + } + if (statusFilter !== "all") { + params.append("status", statusFilter); + } + + const response = await fetch(`/api/v1/remittance/history?${params}`); + if (!response.ok) { + throw new Error(t("transactionHistory.alerts.fetchFailed")); + } + + const data = await response.json(); + + if (data.userAddress) { + setUserAddress(data.userAddress); + } + + if (reset) { + setTransactions(data.transactions || []); + } else { + setTransactions((prev) => [...prev, ...(data.transactions || [])]); + } + + setCursor(data.nextCursor); + setHasMore(!!data.nextCursor); + } catch (err) { + setError( + err instanceof Error + ? err.message + : t("transactionHistory.alerts.genericError") + ); + } finally { + setLoading(false); + setInitialLoading(false); + setLoadingMore(false); } - - setCursor(data.nextCursor); - setHasMore(!!data.nextCursor); - } catch (err) { - setError(err instanceof Error ? err.message : t('transactionHistory.alerts.genericError')); - } finally { - setLoading(false); - } - }, [statusFilter]); + }, + [statusFilter, t] + ); useEffect(() => { fetchTransactions(undefined, true); }, [fetchTransactions]); - const handleLoadMore = () => { - if (hasMore && !loading) { + const handleLoadMore = useCallback(() => { + if (hasMore && !loadingMore) { fetchTransactions(cursor, false); } - }; - - const handleFilterClick = () => { - // TODO: Implement filter modal/dropdown - alert(t('transactionHistory.alerts.filtersSoon')); - }; - - const handleExportClick = () => { - // TODO: Implement export functionality - alert(t('transactionHistory.alerts.exportSoon')); - }; - - const filteredTransactions = transactions.filter(tx => - tx.hash.toLowerCase().includes(searchTerm.toLowerCase()) || - tx.recipient.toLowerCase().includes(searchTerm.toLowerCase()) || - tx.sender.toLowerCase().includes(searchTerm.toLowerCase()) || - (tx.memo && tx.memo.toLowerCase().includes(searchTerm.toLowerCase())) - ); + }, [hasMore, loadingMore, cursor, fetchTransactions]); + + const handleClearFilters = useCallback(() => { + setSearchTerm(""); + setStatusFilter("all"); + setDirectionFilter("all"); + setDateFrom(""); + setDateTo(""); + }, []); + + const hasActiveFilters = + debouncedSearch.trim().length > 0 || + statusFilter !== "all" || + directionFilter !== "all" || + dateFrom.length > 0 || + dateTo.length > 0; + + const filteredTransactions = useMemo(() => { + const query = debouncedSearch.trim().toLowerCase(); + + return transactions.filter((tx) => { + if (statusFilter !== "all") { + if (tx.status === "pending" && statusFilter !== "pending") return false; + if (tx.status === "completed" && statusFilter !== "completed") return false; + if (tx.status === "failed" && statusFilter !== "failed") return false; + } + + if (directionFilter !== "all" && userAddress) { + const isSent = tx.sender === userAddress; + if (directionFilter === "sent" && !isSent) return false; + if (directionFilter === "received" && isSent) return false; + } + + if (dateFrom) { + const txDate = new Date(tx.date); + const fromDate = new Date(dateFrom); + if (txDate < fromDate) return false; + } + if (dateTo) { + const txDate = new Date(tx.date); + const toDate = new Date(dateTo); + toDate.setHours(23, 59, 59, 999); + if (txDate > toDate) return false; + } + + if (query.length > 0) { + const searchableText = [ + tx.hash, + tx.recipient, + tx.sender, + tx.memo || "", + tx.amount, + tx.currency, + tx.id, + ] + .join(" ") + .toLowerCase(); + if (!searchableText.includes(query)) return false; + } + + return true; + }); + }, [transactions, debouncedSearch, statusFilter, directionFilter, dateFrom, dateTo, userAddress]); + + const groupedTransactions = useMemo(() => { + const groups: Record = { + today: [], + yesterday: [], + earlier: [], + }; + + filteredTransactions.forEach((tx) => { + const txDate = new Date(tx.date); + const groupKey = getGroupKey(txDate, todayStart, yesterdayStart); + const isSent = userAddress ? tx.sender === userAddress : tx.sender !== tx.recipient; + + const componentTx: Transaction = { + id: tx.hash.slice(0, 8), + hash: tx.hash, + type: isSent ? "Send Money" : "Received", + amount: parseFloat(tx.amount) * (isSent ? -1 : 1), + currency: tx.currency, + counterpartyName: isSent ? tx.recipient : tx.sender, + counterpartyLabel: isSent ? "To" : "From", + date: new Date(tx.date).toLocaleString(), + fee: 0, + status: (tx.status === "completed" + ? "Completed" + : tx.status === "failed" + ? "Failed" + : "Pending") as TransactionStatus, + }; - // Convert API transaction format to component format - const convertToComponentTransaction = (tx: TransactionItem): import('@/components/Dashboard/TransactionHistoryItem').Transaction => ({ - id: tx.hash.slice(0, 8), // Short hash for display - hash: tx.hash, // Full hash for explorer link - type: (tx.sender === tx.recipient ? 'Received' : 'Send Money') as 'Send Money' | 'Received', - amount: parseFloat(tx.amount), - currency: tx.currency, - counterpartyName: tx.sender === tx.recipient ? tx.sender : tx.recipient, - counterpartyLabel: tx.sender === tx.recipient ? 'From' : 'To', - date: new Date(tx.date).toLocaleString(), - fee: 0.01, // Default fee - should come from API if available - status: (tx.status === 'completed' ? 'Completed' : - tx.status === 'failed' ? 'Failed' : 'Pending') as 'Completed' | 'Failed' | 'Pending', - }); - - const resultsSubtitle = - filteredTransactions.length > 0 - ? t('transactionHistory.resultsCount').replace('{{count}}', `${filteredTransactions.length}`) - : t('transactionHistory.resultsCountZero'); - - const statusLabels: Record = { - all: t('transactionHistory.tabs.all'), - completed: t('transactionHistory.tabs.completed'), - pending: t('transactionHistory.tabs.pending'), - failed: t('transactionHistory.tabs.failed'), - }; + groups[groupKey].push(componentTx); + }); + + return groups; + }, [filteredTransactions, todayStart, yesterdayStart, userAddress]); + + const totalCount = transactions.length; + const filteredCount = filteredTransactions.length; + const isLoading = initialLoading && loading; + const noTransactions = !isLoading && !error && totalCount === 0; + const noResults = !isLoading && !error && totalCount > 0 && filteredCount === 0 && hasActiveFilters; + + const resultsAriaLive = useMemo(() => { + if (filteredCount === 0) return t("transactionHistory.resultsAriaLive.none"); + if (filteredCount === 1) return t("transactionHistory.resultsAriaLive.one"); + return t("transactionHistory.resultsAriaLive.many").replace( + "{{count}}", + String(filteredCount) + ); + }, [filteredCount, t]); return (
0 + ? t("transactionHistory.resultsCount").replace( + "{{count}}", + String(totalCount) + ) + : t("transactionHistory.resultsCountZero") + } /> - +
- {/* Search and Filter Bar */} + {/* Search and Action Bar */}
-
- {/* Status Filter Tabs */} -
- {(['all', 'completed', 'pending', 'failed'] as const).map((status) => ( - - ))} + {/* Filters Panel */} +
+
+ +

+ {t("transactionHistory.filtersHeading")} +

+
+ + {/* Status Tabs */} +
+ + {t("transactionHistory.statusFilterLabel", "Status")} + +
+ {(["all", "completed", "pending", "failed"] as const).map( + (status) => ( + + ) + )} +
+
+ + {/* Type / Direction Filter */} +
+ + {t("transactionHistory.typeFilterLabel", "Type")} + +
+ {(["all", "sent", "received"] as const).map((direction) => ( + + ))} +
+
+ + {/* Date Range Filter */} +
+ + {t("transactionHistory.dateRange.label")} + +
+
+ + setDateFrom(e.target.value)} + className="min-h-[40px] rounded-xl border border-[#FFFFFF14] bg-[#1A1A1A] px-3 py-2 text-sm text-white focus:border-red-400/40 focus:outline-none focus:ring-1 focus:ring-red-400/40" + /> +
+
+ + setDateTo(e.target.value)} + min={dateFrom || undefined} + className="min-h-[40px] rounded-xl border border-[#FFFFFF14] bg-[#1A1A1A] px-3 py-2 text-sm text-white focus:border-red-400/40 focus:outline-none focus:ring-1 focus:ring-red-400/40" + /> +
+ {(dateFrom || dateTo) && ( + + )} +
+
+ + {/* Active Filters */} + {hasActiveFilters && ( +
+ + {t("transactionHistory.activeFilters.label")} + + {statusFilter !== "all" && ( + setStatusFilter("all")} + /> + )} + {directionFilter !== "all" && ( + setDirectionFilter("all")} + /> + )} + {debouncedSearch.trim().length > 0 && ( + setSearchTerm("")} + /> + )} + {(dateFrom || dateTo) && ( + { + setDateFrom(""); + setDateTo(""); + }} + /> + )} + +
+ )} +
+ + {/* Results Count (aria-live) */} +
+ {resultsAriaLive}
{/* Error State */} {error && (

{error}

+
+ +
)} {/* Loading State */} - {loading && transactions.length === 0 && ( + {isLoading && (
)} - {/* Transactions List */} - {!loading && filteredTransactions.length === 0 && !error && ( -
-

{t('transactionHistory.empty')}

+ {/* Empty State (no transactions at all) */} + {noTransactions && ( +
+
)} -
- {filteredTransactions.map((tx) => ( - + - ))} -
+
+ )} + + {/* Grouped Transactions List */} + {!isLoading && filteredCount > 0 && ( +
+ {(["today", "yesterday", "earlier"] as GroupKey[]).map( + (groupKey) => { + const txs = groupedTransactions[groupKey]; + if (txs.length === 0) return null; + + return ( +
+
+

+ {groupLabels[groupKey].label} +

+ + {txs.length}{" "} + {txs.length === 1 + ? t("transactionHistory.results_one").replace( + "{{count}}", + "1" + ) + : t("transactionHistory.results_many").replace( + "{{count}}", + String(txs.length) + )} + +
+
+ {txs.map((tx) => ( + + ))} +
+
+ ); + } + )} +
+ )} {/* Load More Button */} - {hasMore && !loading && filteredTransactions.length > 0 && ( + {hasMore && !loading && filteredCount > 0 && (
)} {/* Loading More Indicator */} - {loading && transactions.length > 0 && ( -
+ {loadingMore && filteredCount > 0 && ( +
)} @@ -212,4 +626,26 @@ const TransactionHistoryPage = () => { ); }; +function ActivePill({ + label, + onRemove, +}: { + label: string; + onRemove: () => void; +}) { + return ( + + {label} + + + ); +} + export default TransactionHistoryPage; diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index eecb34f..4d0c8b6 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -8,8 +8,9 @@ import { FileText, FilterIcon, GitBranch, - Info, + Inbox, Search, + SearchX, Send, Shield, X, @@ -20,6 +21,9 @@ import TransactionHistoryItem, { TransactionType, } from "@/components/Dashboard/TransactionHistoryItem"; import { useDensity } from "@/lib/context/DensityContext"; +import { useClientTranslator } from "@/lib/i18n/client"; +import { useDebounce } from "@/lib/hooks/useDebounce"; +import WidgetEmptyState from "@/components/ui/WidgetEmptyState"; const allTransactions: Transaction[] = [ { @@ -231,49 +235,26 @@ const statusStyles: Record< }, }; -type GroupKey = "today" | "this-week" | "earlier"; - -const groupLabels: Record = { - today: { - label: "Today", - helper: "Transactions from the latest activity day", - }, - "this-week": { - label: "This Week", - helper: "Activity within 7 days of the latest transaction", - }, - earlier: { - label: "Earlier", - helper: "Older activity kept in chronological order", - }, -}; - -function parseTransactionDate(date: string) { - return new Date(date.replace(" ", "T")); -} +type GroupKey = "today" | "yesterday" | "earlier"; function startOfDay(date: Date) { return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } -function getLatestTransactionDate(transactions: Transaction[]) { - return transactions.reduce((latest, transaction) => { - const transactionDate = parseTransactionDate(transaction.date); - return transactionDate > latest ? transactionDate : latest; - }, parseTransactionDate(transactions[0]?.date ?? "2026-06-02 00:00:00")); -} - -function getGroupKey(transaction: Transaction, referenceDate: Date): GroupKey { - const transactionDay = startOfDay(parseTransactionDate(transaction.date)); - const referenceDay = startOfDay(referenceDate); - const dayDifference = - (referenceDay.getTime() - transactionDay.getTime()) / (1000 * 60 * 60 * 24); +function getGroupKey(date: Date): GroupKey { + const d = startOfDay(date); + const today = startOfDay(new Date()); + const yesterday = startOfDay(new Date(Date.now() - 86400000)); - if (dayDifference === 0) return "today"; - if (dayDifference > 0 && dayDifference <= 7) return "this-week"; + if (d.getTime() === today.getTime()) return "today"; + if (d.getTime() === yesterday.getTime()) return "yesterday"; return "earlier"; } +function parseTransactionDate(date: string) { + return new Date(date.replace(" ", "T")); +} + function formatShortDate(date: string) { return new Intl.DateTimeFormat("en", { month: "short", @@ -287,18 +268,32 @@ function normalizeQuery(value: string) { } export default function TransactionsPage() { + const { t } = useClientTranslator(); const { density } = useDensity(); const [searchQuery, setSearchQuery] = useState(""); + const debouncedSearch = useDebounce(searchQuery, 300); const [selectedTypes, setSelectedTypes] = useState([]); const [selectedStatuses, setSelectedStatuses] = useState([]); - - const referenceDate = useMemo( - () => getLatestTransactionDate(allTransactions), - [] - ); + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + + const groupLabels: Record = { + today: { + label: t("transactionHistory.dateGroups.today"), + helper: t("transactionHistory.dateGroups.todayHelper"), + }, + yesterday: { + label: t("transactionHistory.dateGroups.yesterday"), + helper: t("transactionHistory.dateGroups.yesterdayHelper"), + }, + earlier: { + label: t("transactionHistory.dateGroups.earlier"), + helper: t("transactionHistory.dateGroups.earlierHelper"), + }, + }; const filteredTransactions = useMemo(() => { - const query = normalizeQuery(searchQuery); + const query = normalizeQuery(debouncedSearch); return allTransactions.filter((transaction) => { const searchableText = [ @@ -319,28 +314,44 @@ export default function TransactionsPage() { selectedStatuses.length === 0 || selectedStatuses.includes(transaction.status); - return matchesSearch && matchesType && matchesStatus; + let matchesDate = true; + const txDate = parseTransactionDate(transaction.date); + if (dateFrom) { + const from = new Date(dateFrom); + if (txDate < from) matchesDate = false; + } + if (dateTo) { + const to = new Date(dateTo); + to.setHours(23, 59, 59, 999); + if (txDate > to) matchesDate = false; + } + + return matchesSearch && matchesType && matchesStatus && matchesDate; }); - }, [searchQuery, selectedStatuses, selectedTypes]); + }, [debouncedSearch, selectedStatuses, selectedTypes, dateFrom, dateTo]); const groupedTransactions = useMemo(() => { const groups: Record = { today: [], - "this-week": [], + yesterday: [], earlier: [], }; filteredTransactions.forEach((transaction) => { - groups[getGroupKey(transaction, referenceDate)].push(transaction); + groups[getGroupKey(parseTransactionDate(transaction.date))].push( + transaction + ); }); return groups; - }, [filteredTransactions, referenceDate]); + }, [filteredTransactions]); const activeFilterCount = selectedTypes.length + selectedStatuses.length + - (normalizeQuery(searchQuery).length > 0 ? 1 : 0); + (normalizeQuery(debouncedSearch).length > 0 ? 1 : 0) + + (dateFrom.length > 0 ? 1 : 0) + + (dateTo.length > 0 ? 1 : 0); const hasTransactions = allTransactions.length > 0; const hasNoResults = hasTransactions && filteredTransactions.length === 0; @@ -365,6 +376,8 @@ export default function TransactionsPage() { setSearchQuery(""); setSelectedTypes([]); setSelectedStatuses([]); + setDateFrom(""); + setDateTo(""); }; const handleExportClick = () => { @@ -404,14 +417,13 @@ export default function TransactionsPage() {

- Transaction history + {t("transactionHistory.titleStandalone")}

USDC activity

- Search, filter, and review payment, split, bill, and insurance - activity with grouped results by date. + {t("transactionHistory.subtitleStandalone")}

@@ -437,18 +449,20 @@ export default function TransactionsPage() { id="transaction-filters-heading" className="text-sm font-semibold text-white" > - Filter transactions + {t("transactionHistory.filtersHeading")}

- Search combines with every selected type and status chip. + {t("transactionHistory.filtersHelper")}

@@ -482,7 +497,7 @@ export default function TransactionsPage() {
- Type + {t("transactionHistory.typeFilterLabel", "Type")}
- Status + {t("transactionHistory.statusFilterLabel", "Status")}
+ {/* Date Range Filter */} +
+
+ + {t("transactionHistory.dateRange.label")} + +
+
+ + setDateFrom(e.target.value)} + className="min-h-[40px] rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-white focus:border-red-400/40 focus:outline-none focus:ring-1 focus:ring-red-400/40" + /> +
+
+ + setDateTo(e.target.value)} + min={dateFrom || undefined} + className="min-h-[40px] rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-white focus:border-red-400/40 focus:outline-none focus:ring-1 focus:ring-red-400/40" + /> +
+ {(dateFrom || dateTo) && ( + + )} +
+
+
+
- Active + {t("transactionHistory.activeFilters.label")} {activeFilterCount === 0 ? ( - No filters applied + {t("transactionHistory.activeFilters.none")} ) : ( <> - {normalizeQuery(searchQuery).length > 0 && ( + {normalizeQuery(debouncedSearch).length > 0 && ( setSearchQuery("")} /> )} {selectedTypes.map((type) => ( toggleType(type)} /> ))} {selectedStatuses.map((status) => ( toggleStatus(status)} /> ))} + {(dateFrom || dateTo) && ( + { + setDateFrom(""); + setDateTo(""); + }} + /> + )} )}
@@ -576,37 +664,44 @@ export default function TransactionsPage() { disabled={activeFilterCount === 0} className="inline-flex min-h-[40px] items-center justify-center rounded-xl border border-white/10 px-3 py-2 text-sm font-medium text-gray-300 transition hover:border-white/20 hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-300 disabled:cursor-not-allowed disabled:text-gray-600 disabled:hover:bg-transparent" > - Clear all + {t("transactionHistory.activeFilters.clearAll")}
{!hasTransactions && ( - )} {hasNoResults && ( - )} {filteredTransactions.length > 0 && (
- {(["today", "this-week", "earlier"] as GroupKey[]).map( + {(["today", "yesterday", "earlier"] as GroupKey[]).map( (groupKey) => { const transactions = groupedTransactions[groupKey]; if (transactions.length === 0) return null; return ( -
+

{transactions.length}{" "} - {transactions.length === 1 ? "result" : "results"} + {transactions.length === 1 + ? t("transactionHistory.results_one").replace( + "{{count}}", + "1" + ) + : t("transactionHistory.results_many").replace( + "{{count}}", + String(transactions.length) + )}

); } - -function StateCard({ - actionLabel, - body, - onAction, - title, -}: { - actionLabel?: string; - body: string; - onAction?: () => void; - title: string; -}) { - return ( -
-
- -
-

{title}

-

- {body} -

- {actionLabel && onAction && ( - - )} -
- ); -} diff --git a/components/ui/WidgetEmptyState.tsx b/components/ui/WidgetEmptyState.tsx index d824aa2..74f8468 100644 --- a/components/ui/WidgetEmptyState.tsx +++ b/components/ui/WidgetEmptyState.tsx @@ -1,23 +1,45 @@ -import Link from 'next/link' -import { LucideIcon } from 'lucide-react' - -interface WidgetEmptyStateProps { - icon: LucideIcon - title: string - description: string - ctaLabel: string - ctaHref: string +"use client"; + +import Link from "next/link"; +import { type LucideIcon } from "lucide-react"; + +interface WidgetEmptyStateBaseProps { + icon: LucideIcon; + title: string; + description: string; } -export default function WidgetEmptyState({ - icon: Icon, - title, - description, - ctaLabel, - ctaHref, -}: WidgetEmptyStateProps) { +type WidgetEmptyStateWithLink = WidgetEmptyStateBaseProps & { + ctaLabel: string; + ctaHref: string; + onAction?: never; +}; + +type WidgetEmptyStateWithAction = WidgetEmptyStateBaseProps & { + ctaLabel: string; + onAction: () => void; + ctaHref?: never; +}; + +type WidgetEmptyStateNoCTA = WidgetEmptyStateBaseProps & { + ctaLabel?: never; + ctaHref?: never; + onAction?: never; +}; + +type WidgetEmptyStateProps = + | WidgetEmptyStateWithLink + | WidgetEmptyStateWithAction + | WidgetEmptyStateNoCTA; + +export default function WidgetEmptyState(props: WidgetEmptyStateProps) { + const { icon: Icon, title, description, ctaLabel, ctaHref, onAction } = props; + return ( -
+
@@ -25,12 +47,23 @@ export default function WidgetEmptyState({

{title}

{description}

- - {ctaLabel} - + {ctaLabel && ctaHref && ( + + {ctaLabel} + + )} + {ctaLabel && onAction && ( + + )}
- ) + ); } diff --git a/lib/hooks/useDebounce.ts b/lib/hooks/useDebounce.ts new file mode 100644 index 0000000..7eec7f0 --- /dev/null +++ b/lib/hooks/useDebounce.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Returns a debounced version of the given value. + * The returned value only updates after `delay` ms of inactivity. + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index adfe367..b590e55 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -30,11 +30,16 @@ }, "transactionHistory": { "title": "Transaction History", + "titleStandalone": "Transaction History", + "subtitleStandalone": "Search, filter, and review payment, split, bill, and insurance activity with grouped results by date.", "resultsCount": "{{count}} transactions found", "resultsCountZero": "No transactions found", "searchPlaceholder": "Search by transaction hash, address, or memo...", "searchPlaceholderMobile": "Search transactions...", + "searchStandalonePlaceholder": "Search ID, recipient, type, status, amount", "filters": "Filter Results", + "filtersHeading": "Filter transactions", + "filtersHelper": "Search combines with every selected type and status chip.", "export": "Export History", "tabs": { "all": "All Transactions", @@ -42,8 +47,69 @@ "pending": "Pending Review", "failed": "Needs Attention" }, + "typeFilterLabel": "Type", + "statusFilterLabel": "Status", + "typeFilter": { + "all": "All types", + "send": "Send", + "split": "Split", + "bill": "Bills", + "insurance": "Insurance", + "savings": "Savings", + "family": "Family", + "received": "Received" + }, + "statusFilter": { + "all": "All statuses", + "completed": "Completed", + "pending": "Pending", + "failed": "Failed" + }, + "dateRange": { + "label": "Date range", + "from": "From date", + "to": "To date", + "clear": "Clear dates" + }, + "dateGroups": { + "today": "Today", + "todayHelper": "Transactions from today", + "yesterday": "Yesterday", + "yesterdayHelper": "Transactions from yesterday", + "earlier": "Earlier", + "earlierHelper": "Older activity kept in chronological order" + }, + "activeFilters": { + "label": "Active", + "none": "No filters applied", + "search": "Search: {{query}}", + "type": "Type: {{type}}", + "status": "Status: {{status}}", + "clearAll": "Clear all", + "clearSearch": "Clear transaction search", + "removeFilter": "Remove {{label}} filter" + }, "empty": "No transactions matched your search.", + "noResults": { + "title": "No matching transactions", + "description": "Try clearing a filter, widening the status selection, or searching by recipient, ID, amount, or transaction type.", + "clearFilters": "Clear filters" + }, + "emptyState": { + "title": "No transaction history yet", + "description": "Completed transfers, splits, bill payments, and insurance activity will appear here once you make your first transaction.", + "cta": "Send Money" + }, + "resultsAriaLive": { + "one": "1 transaction found", + "many": "{{count}} transactions found", + "none": "No transactions found" + }, "loadMore": "Load More Results", + "showing": "Showing {{count}} of {{total}} transactions", + "results_one": "{{count}} result", + "results_many": "{{count}} results", + "clearSearch": "Clear search", "alerts": { "filtersSoon": "Filter functionality coming soon!", "exportSoon": "Export functionality coming soon!", diff --git a/lib/i18n/locales/es.json b/lib/i18n/locales/es.json index 31890e5..cfd426d 100644 --- a/lib/i18n/locales/es.json +++ b/lib/i18n/locales/es.json @@ -30,11 +30,16 @@ }, "transactionHistory": { "title": "Historial de transacciones", + "titleStandalone": "Historial de transacciones", + "subtitleStandalone": "Busca, filtra y revisa actividad de pagos, divisiones, facturas y seguros con resultados agrupados por fecha.", "resultsCount": "{{count}} transacciones encontradas", "resultsCountZero": "No se encontraron transacciones", "searchPlaceholder": "Busca por hash de transaccion, direccion o memo...", "searchPlaceholderMobile": "Busca transacciones...", + "searchStandalonePlaceholder": "Busca ID, destinatario, tipo, estado, monto", "filters": "Filtrar resultados", + "filtersHeading": "Filtrar transacciones", + "filtersHelper": "La busqueda se combina con cada chip de tipo y estado seleccionado.", "export": "Exportar historial", "tabs": { "all": "Todas las transacciones", @@ -42,8 +47,69 @@ "pending": "Pendientes de revision", "failed": "Requieren atencion" }, + "typeFilterLabel": "Tipo", + "statusFilterLabel": "Estado", + "typeFilter": { + "all": "Todos los tipos", + "send": "Enviar", + "split": "Dividir", + "bill": "Facturas", + "insurance": "Seguro", + "savings": "Ahorros", + "family": "Familia", + "received": "Recibido" + }, + "statusFilter": { + "all": "Todos los estados", + "completed": "Completado", + "pending": "Pendiente", + "failed": "Fallido" + }, + "dateRange": { + "label": "Rango de fechas", + "from": "Fecha desde", + "to": "Fecha hasta", + "clear": "Limpiar fechas" + }, + "dateGroups": { + "today": "Hoy", + "todayHelper": "Transacciones de hoy", + "yesterday": "Ayer", + "yesterdayHelper": "Transacciones de ayer", + "earlier": "Anteriores", + "earlierHelper": "Actividad mas antigua en orden cronologico" + }, + "activeFilters": { + "label": "Activos", + "none": "Sin filtros aplicados", + "search": "Busqueda: {{query}}", + "type": "Tipo: {{type}}", + "status": "Estado: {{status}}", + "clearAll": "Limpiar todo", + "clearSearch": "Limpiar busqueda de transacciones", + "removeFilter": "Eliminar filtro {{label}}" + }, "empty": "Ninguna transaccion coincide con tu busqueda.", + "noResults": { + "title": "Sin transacciones coincidentes", + "description": "Intenta limpiar un filtro, ampliar la seleccion de estado, o buscar por destinatario, ID, monto o tipo de transaccion.", + "clearFilters": "Limpiar filtros" + }, + "emptyState": { + "title": "Aun no hay historial de transacciones", + "description": "Las transferencias completadas, divisiones, pagos de facturas y actividad de seguros apareceran aqui una vez que hagas tu primera transaccion.", + "cta": "Enviar dinero" + }, + "resultsAriaLive": { + "one": "1 transaccion encontrada", + "many": "{{count}} transacciones encontradas", + "none": "No se encontraron transacciones" + }, "loadMore": "Cargar mas resultados", + "showing": "Mostrando {{count}} de {{total}} transacciones", + "results_one": "{{count}} resultado", + "results_many": "{{count}} resultados", + "clearSearch": "Limpiar busqueda", "alerts": { "filtersSoon": "La funcion de filtros estara disponible pronto.", "exportSoon": "La exportacion estara disponible pronto.",