diff --git a/app/globals.css b/app/globals.css index f703a7b..c207280 100644 --- a/app/globals.css +++ b/app/globals.css @@ -83,6 +83,31 @@ to { width: 100%; } } +/* Skeleton shimmer (#693) — linear-gradient sweep for loading placeholders. + * Applied alongside `animate-pulse` so skeletons shimmer instead of looking + * like flat, broken gray boxes. */ +@keyframes skeleton-shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.skeleton-shimmer { + background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.5s ease-in-out infinite; +} + +.dark .skeleton-shimmer { + background: linear-gradient(90deg, #374151 25%, #4b5563 50%, #374151 75%); + background-size: 200% 100%; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton-shimmer { + animation: none; + } +} + /* ── Print stylesheet (#555, #571) ────────────────────────────────────────── */ /* * Applied via .print-receipt on /transactions and /stream/[id]. diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx index 6fe8829..551f253 100644 --- a/app/transactions/page.tsx +++ b/app/transactions/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useMemo, useState } from 'react'; import { AlertCircle, RefreshCw, Info, Download, Printer } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Card } from '@/components/ui/Card'; @@ -46,6 +47,32 @@ const STATUS_CLASS: Record = { Failed: 'text-gray-500 bg-gray-200 line-through', }; +// Status filter tabs (#692) — client-side only so users troubleshooting a +// stuck/failed transaction don't scroll through dozens of confirmed items. +// "Confirmed" maps to the indexer's `Success` status; "Failed" also matches a +// `Reverted` status alias for chains that report reverts under that name. +export type TransactionStatusFilter = 'All' | 'Confirmed' | 'Pending' | 'Failed'; + +export const TRANSACTION_STATUS_FILTERS: readonly TransactionStatusFilter[] = [ + 'All', + 'Confirmed', + 'Pending', + 'Failed', +] as const; + +/** Normalizes a row status for filter comparison (case-insensitive). */ +export function matchesStatusFilter( + status: string, + filter: TransactionStatusFilter, +): boolean { + if (filter === 'All') return true; + const normalized = status.trim().toLowerCase(); + if (filter === 'Confirmed') return normalized === 'success' || normalized === 'confirmed'; + if (filter === 'Pending') return normalized === 'pending'; + // Failed covers both "Failed" and the "Reverted" alias from the issue title. + return normalized === 'failed' || normalized === 'reverted'; +} + export default function TransactionsPage() { const { publicKey, connected } = useWallet(); const { timeFormat } = useSettings(); @@ -56,10 +83,31 @@ export default function TransactionsPage() { retry: (failureCount, queryError) => !isIndexerNotConfiguredError(queryError) && failureCount < 1, }); + const [statusFilter, setStatusFilter] = useState('All'); + + const filteredTxs = useMemo( + () => txs.filter((tx) => matchesStatusFilter(tx.status, statusFilter)), + [txs, statusFilter], + ); + + const statusCounts = useMemo(() => { + const counts: Record = { + All: txs.length, + Confirmed: 0, + Pending: 0, + Failed: 0, + }; + for (const tx of txs) { + if (matchesStatusFilter(tx.status, 'Confirmed')) counts.Confirmed += 1; + else if (matchesStatusFilter(tx.status, 'Pending')) counts.Pending += 1; + else if (matchesStatusFilter(tx.status, 'Failed')) counts.Failed += 1; + } + return counts; + }, [txs]); const isDemoData = connected && txs.length > 0; const isIndexerComingSoon = status === 'error' && isIndexerNotConfiguredError(error); - const canExport = txs.length > 0; + const canExport = filteredTxs.length > 0; /** Format a timestamp respecting the user's time-format preference (#556). */ const formatDate = (ts: number) => @@ -68,7 +116,7 @@ export default function TransactionsPage() { const handleExport = () => { if (!canExport) return; const stamp = new Date().toISOString().slice(0, 10); - downloadCsv(`conduit-transactions-${stamp}.csv`, transactionsToCsv(txs)); + downloadCsv(`conduit-transactions-${stamp}.csv`, transactionsToCsv(filteredTxs)); }; const handlePrint = () => window.print(); @@ -164,10 +212,45 @@ export default function TransactionsPage() { ) : ( + <> + {/* Status filter tabs — client-side only (#692) */} +
+ {TRANSACTION_STATUS_FILTERS.map((filter) => { + const isSelected = statusFilter === filter; + return ( + + ); + })} +
+ + {filteredTxs.length === 0 ? ( + +
+ No {statusFilter === 'All' ? '' : `${statusFilter} `}transactions. +
+
+ ) : ( {/* Mobile Layout */}
- {txs.map((tx) => { + {filteredTxs.map((tx) => { const isPositive = tx.type === 'Stream Created'; const isNegative = tx.type === 'Withdrawn'; const isCancelled = tx.type === 'Cancelled'; @@ -226,7 +309,7 @@ export default function TransactionsPage() { - {txs.map((tx) => { + {filteredTxs.map((tx) => { const isPositive = tx.type === 'Stream Created'; const isNegative = tx.type === 'Withdrawn'; const isCancelled = tx.type === 'Cancelled'; @@ -264,6 +347,8 @@ export default function TransactionsPage() {
+ )} + )} ); diff --git a/components/OnboardingTour.tsx b/components/OnboardingTour.tsx index 8c97a5a..076a225 100644 --- a/components/OnboardingTour.tsx +++ b/components/OnboardingTour.tsx @@ -15,15 +15,36 @@ export function OnboardingTour() { const totalSteps = ONBOARDING_STEPS.length; return ( - + + Skip Tour + + } + >

{currentStep.description}

- {/* Step indicator */} -
- {ONBOARDING_STEPS.map((_, idx) => ( + {/* Step indicator — dots + "Step X of N" counter (#694) */} +
+ {ONBOARDING_STEPS.map((step, idx) => (