Skip to content
Merged
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
25 changes: 25 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
93 changes: 89 additions & 4 deletions app/transactions/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -46,6 +47,32 @@ const STATUS_CLASS: Record<string, string> = {
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();
Expand All @@ -56,10 +83,31 @@ export default function TransactionsPage() {
retry: (failureCount, queryError) =>
!isIndexerNotConfiguredError(queryError) && failureCount < 1,
});
const [statusFilter, setStatusFilter] = useState<TransactionStatusFilter>('All');

const filteredTxs = useMemo(
() => txs.filter((tx) => matchesStatusFilter(tx.status, statusFilter)),
[txs, statusFilter],
);

const statusCounts = useMemo(() => {
const counts: Record<TransactionStatusFilter, number> = {
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) =>
Expand All @@ -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();
Expand Down Expand Up @@ -164,10 +212,45 @@ export default function TransactionsPage() {
</div>
</Card>
) : (
<>
{/* Status filter tabs — client-side only (#692) */}
<div
role="tablist"
aria-label="Filter transactions by status"
className="flex flex-wrap gap-2 mb-4 print:hidden"
>
{TRANSACTION_STATUS_FILTERS.map((filter) => {
const isSelected = statusFilter === filter;
return (
<button
key={filter}
type="button"
role="tab"
aria-selected={isSelected}
onClick={() => setStatusFilter(filter)}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
isSelected
? 'bg-black text-white dark:bg-white dark:text-black'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{filter} ({statusCounts[filter]})
</button>
);
})}
</div>

{filteredTxs.length === 0 ? (
<Card>
<div className="py-16 text-center text-sm text-gray-400">
No {statusFilter === 'All' ? '' : `${statusFilter} `}transactions.
</div>
</Card>
) : (
<Card padded={false}>
{/* Mobile Layout */}
<div className="sm:hidden flex flex-col divide-y divide-gray-100">
{txs.map((tx) => {
{filteredTxs.map((tx) => {
const isPositive = tx.type === 'Stream Created';
const isNegative = tx.type === 'Withdrawn';
const isCancelled = tx.type === 'Cancelled';
Expand Down Expand Up @@ -226,7 +309,7 @@ export default function TransactionsPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{txs.map((tx) => {
{filteredTxs.map((tx) => {
const isPositive = tx.type === 'Stream Created';
const isNegative = tx.type === 'Withdrawn';
const isCancelled = tx.type === 'Cancelled';
Expand Down Expand Up @@ -264,6 +347,8 @@ export default function TransactionsPage() {
</table>
</div>
</Card>
)}
</>
)}
</div>
);
Expand Down
31 changes: 26 additions & 5 deletions components/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,36 @@ export function OnboardingTour() {
const totalSteps = ONBOARDING_STEPS.length;

return (
<Modal onClose={skip} title={currentStep.title}>
<Modal
onClose={skip}
title={currentStep.title}
headerAction={
<button
type="button"
onClick={skip}
aria-label="Skip tour"
className="text-xs font-medium text-gray-500 underline underline-offset-2 hover:text-black dark:text-gray-400 dark:hover:text-white transition-colors"
>
Skip Tour
</button>
}
>
<div className="space-y-4">
<p className="text-gray-600">{currentStep.description}</p>

{/* Step indicator */}
<div className="flex items-center gap-2">
{ONBOARDING_STEPS.map((_, idx) => (
{/* Step indicator — dots + "Step X of N" counter (#694) */}
<div
className="flex items-center gap-2"
role="progressbar"
aria-valuemin={1}
aria-valuemax={totalSteps}
aria-valuenow={stepNumber}
aria-label={`Step ${stepNumber} of ${totalSteps}`}
>
{ONBOARDING_STEPS.map((step, idx) => (
<div
key={idx}
key={step.id}
aria-hidden="true"
className={`h-2 flex-1 rounded-full transition-colors ${
idx <= currentStepIndex ? 'bg-black' : 'bg-gray-200'
}`}
Expand Down
18 changes: 10 additions & 8 deletions components/stream/StreamCardSkeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* StreamCardSkeleton
*
* Placeholder that mirrors the exact layout of StreamCard while data loads.
* Uses Tailwind's `animate-pulse` to signal pending state without CLS.
* Uses Tailwind's `animate-pulse` plus a CSS linear-gradient shimmer sweep
* (`.skeleton-shimmer` in globals.css, #693) to signal pending state without
* CLS — static gray boxes alone look broken/unresponsive.
*
* Usage:
* {loading && Array.from({ length: 3 }).map((_, i) => <StreamCardSkeleton key={i} />)}
Expand All @@ -19,27 +21,27 @@ export function StreamCardSkeleton() {
{/* Counterparty block */}
<div className="min-w-0 flex flex-col gap-1">
{/* "From / To" label */}
<div className="h-2.5 w-6 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-2.5 w-6 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer animate-pulse" />
{/* Truncated address */}
<div className="h-3 w-28 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-3 w-28 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer animate-pulse" />
</div>

{/* Rate — centred */}
<div className="flex-1 flex justify-center px-1">
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer animate-pulse" />
</div>

{/* Badge */}
<div className="shrink-0 h-5 w-14 rounded bg-gray-200 dark:bg-gray-700" />
<div className="shrink-0 h-5 w-14 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer animate-pulse" />
</div>

{/* ── Progress bar strip ── */}
<div className="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700 skeleton-shimmer animate-pulse" />

{/* ── Footer row: token address | percentage ── */}
<div className="flex items-center justify-between mt-3">
<div className="h-2.5 w-36 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-2.5 w-6 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-2.5 w-36 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer animate-pulse" />
<div className="h-2.5 w-6 rounded bg-gray-200 dark:bg-gray-700 skeleton-shimmer animate-pulse" />
</div>
</div>
);
Expand Down
21 changes: 13 additions & 8 deletions components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ interface ModalProps {
children: React.ReactNode;
/** Max width class; default 'max-w-md' */
size?: string;
/** Optional action rendered in the header next to the close button (#694). */
headerAction?: React.ReactNode;
}

const FOCUSABLE_SELECTOR = [
Expand All @@ -24,7 +26,7 @@ function getFocusableElements(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
}

export function Modal({ title, onClose, children, size = 'max-w-md' }: ModalProps) {
export function Modal({ title, onClose, children, size = 'max-w-md', headerAction }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);

Expand Down Expand Up @@ -88,13 +90,16 @@ export function Modal({ title, onClose, children, size = 'max-w-md' }: ModalProp
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 id="modal-title" className="font-bold text-sm">{title}</h2>
<button
onClick={onClose}
className="p-1 rounded text-gray-400 hover:text-black hover:bg-gray-100 dark:hover:text-white dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-center gap-2">
{headerAction}
<button
onClick={onClose}
className="p-1 rounded text-gray-400 hover:text-black hover:bg-gray-100 dark:hover:text-white dark:hover:bg-gray-800 transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
</div>

{/* Body */}
Expand Down
Loading
Loading