Skip to content
Open
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
13 changes: 12 additions & 1 deletion app/__tests__/not-found.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('next/link', () => ({
React.createElement('a', { href, ...rest }, children),
}));

import NotFound from '../not-found';
import NotFound, { FEATURED_PUBLIC_STREAMS } from '../not-found';

function render() {
const container = document.createElement('div');
Expand Down Expand Up @@ -57,4 +57,15 @@ describe('NotFound page', () => {
expect(link.textContent?.toLowerCase()).toContain('dashboard');
cleanup();
});

it('renders a 3-item preview grid of trending active streams (#815)', () => {
const { container, cleanup } = render();
const grid = container.querySelector('[data-testid="trending-streams-grid"]');
expect(grid).not.toBeNull();
expect(FEATURED_PUBLIC_STREAMS).toHaveLength(3);
const items = grid!.querySelectorAll('a[href^="/stream/"]');
expect(items.length).toBe(3);
expect(container.textContent).toContain('Trending Active Streams');
cleanup();
});
});
6 changes: 6 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
:root {
--font-geist-sans: 'Geist Sans', system-ui, sans-serif;
--font-geist-mono: 'Geist Mono', ui-monospace, monospace;
transition: background-color 0.3s ease, color 0.3s ease;
}

html,
body {
transition: background-color 0.3s ease, color 0.3s ease;
}

* {
Expand Down
88 changes: 88 additions & 0 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
import Link from 'next/link';

export interface FeaturedStreamPreview {
id: string;
title: string;
token: string;
rate: string;
recipient: string;
}

/**
* Featured public active streams displayed on the 404 page to re-engage visitors (#815).
*/
export const FEATURED_PUBLIC_STREAMS: readonly FeaturedStreamPreview[] = [
{
id: '1',
title: 'Core Protocol Grant',
token: 'XLM',
rate: '1,000 XLM / day',
recipient: 'GCA7...4X2Q',
},
{
id: '2',
title: 'Ecosystem Vesting Pool',
token: 'USDC',
rate: '500 USDC / day',
recipient: 'GD5K...9M1P',
},
{
id: '5',
title: 'Contributor Payroll',
token: 'XLM',
rate: '500 XLM / day',
recipient: 'GB8R...7L3N',
},
];

export default function NotFound() {
return (
<div className="max-w-5xl mx-auto px-4 py-24 flex flex-col items-center text-center gap-6">
Expand Down Expand Up @@ -33,6 +68,59 @@ export default function NotFound() {
<Link href="/dashboard" className="btn-primary text-sm">
Back to Dashboard
</Link>

{/* Trending active streams 3-item preview grid (#815) */}
<section
aria-labelledby="trending-streams-heading"
data-testid="trending-streams-section"
className="w-full max-w-3xl mt-8 pt-8 border-t border-gray-200 dark:border-gray-800 text-left"
>
<div className="flex items-center justify-between mb-4">
<h2
id="trending-streams-heading"
className="text-xs font-bold uppercase tracking-widest text-gray-400 dark:text-gray-500"
>
Trending Active Streams
</h2>
<Link
href="/streams"
className="text-xs text-gray-500 hover:text-black dark:text-gray-400 dark:hover:text-white transition-colors"
>
View all streams →
</Link>
</div>
<div
data-testid="trending-streams-grid"
className="grid grid-cols-1 sm:grid-cols-3 gap-4"
>
{FEATURED_PUBLIC_STREAMS.map((stream) => (
<Link
key={stream.id}
href={`/stream/${stream.id}`}
data-testid={`featured-stream-${stream.id}`}
className="card flex flex-col justify-between gap-3 hover:border-black dark:hover:border-white transition-colors"
>
<div>
<div className="flex items-center justify-between gap-2 mb-2">
<span className="font-mono text-xs text-gray-400 dark:text-gray-500">
#{stream.id}
</span>
<span className="badge-active">Active</span>
</div>
<h3 className="font-semibold text-sm text-black dark:text-white">
{stream.title}
</h3>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<p className="amount font-medium text-black dark:text-white">
{stream.rate}
</p>
<p className="font-mono truncate">To: {stream.recipient}</p>
</div>
</Link>
))}
</div>
</section>
</div>
);
}
23 changes: 22 additions & 1 deletion components/CircuitBreakerOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { CircuitBreakerOverlay } from './CircuitBreakerOverlay';
import { CircuitBreakerOverlay, formatMaintenanceCountdown } from './CircuitBreakerOverlay';

const mockGetCircuitBreakerStates = vi.fn();

Expand Down Expand Up @@ -151,4 +151,25 @@ describe('CircuitBreakerOverlay', () => {

expect(mockGetCircuitBreakerStates.mock.calls.length).toBe(callsBeforeUnmount);
});

it('displays an estimated maintenance recovery countdown when circuit is open (#817)', () => {
mockGetCircuitBreakerStates.mockReturnValue([
{
scope: WRITE_SCOPE,
consecutiveFailures: 3,
circuitOpenUntil: Date.now() + 13_000,
isOpen: true,
remainingMs: 13_000,
},
]);

act(() => {
root.render(<CircuitBreakerOverlay />);
});

const countdownEl = container.querySelector('[data-testid="maintenance-countdown"]');
expect(countdownEl).not.toBeNull();
expect(countdownEl?.textContent).toContain('00:13');
expect(formatMaintenanceCountdown(75_000)).toBe('01:15');
});
});
45 changes: 43 additions & 2 deletions components/CircuitBreakerOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ import { useEffect, useRef, useState } from 'react';
import * as soroban from '@/lib/soroban';
import { type CircuitBreakerState } from '@/lib/soroban';

/**
* Default protocol maintenance recovery window in milliseconds (#817).
*/
export const PROTOCOL_MAINTENANCE_WINDOW_MS = Number(
process.env.NEXT_PUBLIC_MAINTENANCE_WINDOW_MS || 30_000,
);

/**
* Format a remaining millisecond duration as MM:SS for the maintenance recovery countdown (#817).
*/
export function formatMaintenanceCountdown(remainingMs: number): string {
const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

export interface CircuitBreakerOverlayProps {
/** Optional override for protocol maintenance window duration in ms (#817). */
maintenanceWindowMs?: number;
}

/**
* Dev-only overlay that surfaces per-endpoint circuit-breaker state.
*
Expand All @@ -12,7 +34,9 @@ import { type CircuitBreakerState } from '@/lib/soroban';
* next automatic retry attempt and automatically tests the circuit upon expiry.
* Only renders in development builds.
*/
export function CircuitBreakerOverlay() {
export function CircuitBreakerOverlay({
maintenanceWindowMs = PROTOCOL_MAINTENANCE_WINDOW_MS,
}: CircuitBreakerOverlayProps = {}) {
const [states, setStates] = useState<CircuitBreakerState[]>([]);
const [mounted, setMounted] = useState(false);
const testedScopesRef = useRef<Set<string>>(new Set());
Expand Down Expand Up @@ -46,12 +70,28 @@ export function CircuitBreakerOverlay() {

if (!mounted || states.length === 0) return null;

const openStates = states.filter((s) => s.isOpen);
const estimatedRemainingMs =
openStates.length > 0
? Math.max(...openStates.map((s) => s.remainingMs || maintenanceWindowMs))
: 0;
const estimatedRecoverySeconds = Math.ceil(estimatedRemainingMs / 1000);

return (
<div className="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg border border-yellow-400/50 bg-yellow-50 p-3 text-xs shadow-lg dark:border-yellow-600/50 dark:bg-yellow-950/90 dark:text-yellow-100">
<div className="mb-2 font-semibold">Circuit Breaker Monitor</div>
<p className="mb-2 text-[11px] opacity-80">
Some actions are paused while the network recovers. They resume automatically.
</p>
{estimatedRecoverySeconds > 0 && (
<div
data-testid="maintenance-countdown"
aria-live="polite"
className="mb-2 rounded bg-yellow-100/80 px-2 py-1 font-mono text-[11px] font-medium text-yellow-900 dark:bg-yellow-900/50 dark:text-yellow-200"
>
Estimated maintenance recovery: {formatMaintenanceCountdown(estimatedRemainingMs)} ({estimatedRecoverySeconds}s)
</div>
)}
<ul className="space-y-1.5">
{states.map((s) => {
const seconds = Math.ceil(s.remainingMs / 1000);
Expand Down Expand Up @@ -104,7 +144,8 @@ export function CircuitBreakerOverlay() {
fill="none"
strokeDasharray={56.5}
strokeDashoffset={
56.5 * (1 - Math.max(0, Math.min(1, s.remainingMs / 30000)))
56.5 *
(1 - Math.max(0, Math.min(1, s.remainingMs / maintenanceWindowMs)))
}
className="transition-all duration-1000"
/>
Expand Down
45 changes: 44 additions & 1 deletion components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,54 @@
'use client';
import { captureError } from "@/lib/error-tracking";
import { captureError, scrubSensitiveData } from "@/lib/error-tracking";

import React, { Component, ErrorInfo, ReactNode } from 'react';

export const CRASH_TELEMETRY_ENDPOINT = '/api/telemetry/crash';

/**
* Forward caught React render errors to the decentralized crash reporter / telemetry endpoint (#816).
*/
export function reportCrashToTelemetry(
error: Error,
errorInfo: ErrorInfo,
endpoint: string = process.env.NEXT_PUBLIC_CRASH_TELEMETRY_ENDPOINT || CRASH_TELEMETRY_ENDPOINT,
): void {
const payload = JSON.stringify({
name: error.name || 'Error',
message: scrubSensitiveData(error.message || ''),
stack: error.stack ? scrubSensitiveData(error.stack) : undefined,
componentStack: errorInfo.componentStack ? scrubSensitiveData(errorInfo.componentStack) : null,
timestamp: new Date().toISOString(),
source: 'ErrorBoundary',
});

try {
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
navigator.sendBeacon(endpoint, payload);
return;
}
if (typeof fetch === 'function') {
Promise.resolve(
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
}),
).catch(() => {
// Ignore transport errors so telemetry reporting never breaks error recovery
});
}
} catch {
// Ignore synchronous telemetry dispatch errors
}
}

interface Props {
children?: ReactNode;
fallback?: (error: Error, retry: () => void, reset: () => void) => ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
telemetryEndpoint?: string;
}

interface State {
Expand Down Expand Up @@ -52,6 +94,7 @@ export class ErrorBoundary extends Component<Props, State> {
});

captureError(error, { tags: { source: 'ErrorBoundary' }, extra: { componentStack: errorInfo.componentStack } });
reportCrashToTelemetry(error, errorInfo, this.props.telemetryEndpoint);
this.props.onError?.(error, errorInfo);
}

Expand Down
13 changes: 12 additions & 1 deletion components/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest';
import { getNextTheme } from './ThemeToggle';
import {
getNextTheme,
THEME_TRANSITION_STYLE,
applyThemeTransition,
} from './ThemeToggle';

describe('ThemeToggle theme cycling', () => {
it('cycles explicit light mode to dark mode', () => {
Expand All @@ -19,4 +23,11 @@ describe('ThemeToggle theme cycling', () => {
const { THEME_STORAGE_KEY } = require('./ThemeToggle');
expect(THEME_STORAGE_KEY).toBe('streamfi-theme');
});

it('applies smooth cross-fade transition style to root container (#818)', () => {
const el = document.createElement('div');
applyThemeTransition(el);
expect(el.style.transition).toBe(THEME_TRANSITION_STYLE);
expect(THEME_TRANSITION_STYLE).toBe('background-color 0.3s ease, color 0.3s ease');
});
});
25 changes: 21 additions & 4 deletions components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ export function getNextTheme(theme?: string): ThemeChoice {

export const THEME_STORAGE_KEY = 'streamfi-theme';

/**
* Smooth CSS color transition applied to the global root container when switching themes (#818).
*/
export const THEME_TRANSITION_STYLE = 'background-color 0.3s ease, color 0.3s ease';

export function applyThemeTransition(root?: HTMLElement | null): void {
const target =
root ?? (typeof document !== 'undefined' ? document.documentElement : null);
if (!target) return;
target.style.transition = THEME_TRANSITION_STYLE;
if (typeof document !== 'undefined' && document.body) {
document.body.style.transition = THEME_TRANSITION_STYLE;
}
}

export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
Expand All @@ -24,6 +39,7 @@ export function ThemeToggle() {

useEffect(() => {
setMounted(true);
applyThemeTransition();
try {
const stored =
localStorage.getItem(THEME_STORAGE_KEY) ||
Expand Down Expand Up @@ -60,6 +76,7 @@ export function ThemeToggle() {
: 'Switch to light mode';

const handleToggle = () => {
applyThemeTransition();
setTheme(nextTheme);
try {
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
Expand All @@ -72,16 +89,16 @@ export function ThemeToggle() {
return (
<button
onClick={handleToggle}
className="inline-flex items-center justify-center w-9 h-9 rounded text-gray-500 hover:text-black hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800 transition-colors"
className="inline-flex items-center justify-center w-9 h-9 rounded text-gray-500 hover:text-black hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800 transition-colors duration-300 ease-in-out"
aria-label={label}
title={label}
>
{theme === 'system' ? (
<Monitor className="w-4 h-4" />
<Monitor className="w-4 h-4 transition-opacity duration-300 ease-in-out" />
) : isDark ? (
<Sun className="w-4 h-4" />
<Sun className="w-4 h-4 transition-opacity duration-300 ease-in-out" />
) : (
<Moon className="w-4 h-4" />
<Moon className="w-4 h-4 transition-opacity duration-300 ease-in-out" />
)}
</button>
);
Expand Down
Loading
Loading