diff --git a/app/__tests__/not-found.test.tsx b/app/__tests__/not-found.test.tsx
index 82447c5..ffff254 100644
--- a/app/__tests__/not-found.test.tsx
+++ b/app/__tests__/not-found.test.tsx
@@ -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');
@@ -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();
+ });
});
diff --git a/app/globals.css b/app/globals.css
index 7c41d2e..31d29da 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -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;
}
* {
diff --git a/app/not-found.tsx b/app/not-found.tsx
index 51eee3d..2572406 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -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 (
@@ -33,6 +68,59 @@ export default function NotFound() {
Back to Dashboard
+
+ {/* Trending active streams 3-item preview grid (#815) */}
+
+
+
+ Trending Active Streams
+
+
+ View all streams →
+
+
+
+ {FEATURED_PUBLIC_STREAMS.map((stream) => (
+
+
+
+
+ #{stream.id}
+
+ Active
+
+
+ {stream.title}
+
+
+
+
+ {stream.rate}
+
+
To: {stream.recipient}
+
+
+ ))}
+
+
);
}
diff --git a/components/CircuitBreakerOverlay.test.tsx b/components/CircuitBreakerOverlay.test.tsx
index 6a5514f..31a4616 100644
--- a/components/CircuitBreakerOverlay.test.tsx
+++ b/components/CircuitBreakerOverlay.test.tsx
@@ -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();
@@ -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();
+ });
+
+ 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');
+ });
});
diff --git a/components/CircuitBreakerOverlay.tsx b/components/CircuitBreakerOverlay.tsx
index 6ae4c73..40ed0ba 100644
--- a/components/CircuitBreakerOverlay.tsx
+++ b/components/CircuitBreakerOverlay.tsx
@@ -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.
*
@@ -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([]);
const [mounted, setMounted] = useState(false);
const testedScopesRef = useRef>(new Set());
@@ -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 (
Circuit Breaker Monitor
Some actions are paused while the network recovers. They resume automatically.
+ {estimatedRecoverySeconds > 0 && (
+
+ Estimated maintenance recovery: {formatMaintenanceCountdown(estimatedRemainingMs)} ({estimatedRecoverySeconds}s)
+
+ )}
{states.map((s) => {
const seconds = Math.ceil(s.remainingMs / 1000);
@@ -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"
/>
diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx
index e43f809..8e4c0a2 100644
--- a/components/ErrorBoundary.tsx
+++ b/components/ErrorBoundary.tsx
@@ -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 {
@@ -52,6 +94,7 @@ export class ErrorBoundary extends Component {
});
captureError(error, { tags: { source: 'ErrorBoundary' }, extra: { componentStack: errorInfo.componentStack } });
+ reportCrashToTelemetry(error, errorInfo, this.props.telemetryEndpoint);
this.props.onError?.(error, errorInfo);
}
diff --git a/components/ThemeToggle.test.tsx b/components/ThemeToggle.test.tsx
index 4f98e5e..0d4e6d5 100644
--- a/components/ThemeToggle.test.tsx
+++ b/components/ThemeToggle.test.tsx
@@ -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', () => {
@@ -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');
+ });
});
diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx
index b726b7a..1133aa4 100644
--- a/components/ThemeToggle.tsx
+++ b/components/ThemeToggle.tsx
@@ -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);
@@ -24,6 +39,7 @@ export function ThemeToggle() {
useEffect(() => {
setMounted(true);
+ applyThemeTransition();
try {
const stored =
localStorage.getItem(THEME_STORAGE_KEY) ||
@@ -60,6 +76,7 @@ export function ThemeToggle() {
: 'Switch to light mode';
const handleToggle = () => {
+ applyThemeTransition();
setTheme(nextTheme);
try {
localStorage.setItem(THEME_STORAGE_KEY, nextTheme);
@@ -72,16 +89,16 @@ export function ThemeToggle() {
return (
);
diff --git a/components/__tests__/ErrorBoundary.test.tsx b/components/__tests__/ErrorBoundary.test.tsx
index 5db3481..b552a74 100644
--- a/components/__tests__/ErrorBoundary.test.tsx
+++ b/components/__tests__/ErrorBoundary.test.tsx
@@ -1,7 +1,7 @@
import React, { act } from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRoot, type Root } from 'react-dom/client';
-import { ErrorBoundary } from '../ErrorBoundary';
+import { ErrorBoundary, CRASH_TELEMETRY_ENDPOINT } from '../ErrorBoundary';
/**
* Regression tests for issue #92.
@@ -213,4 +213,26 @@ describe('ErrorBoundary (issue #92 regression)', () => {
// Re-create a root so afterEach unmount is a no-op-safe call.
root = createRoot(container);
});
+
+ it('forwards caught render errors to the telemetry crash endpoint (#816)', () => {
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 }));
+ act(() => {
+ root.render(
+ React.createElement(
+ ErrorBoundary,
+ null,
+ React.createElement(Bomb, { shouldThrow: true }),
+ ),
+ );
+ });
+
+ expect(fetchSpy).toHaveBeenCalledWith(
+ CRASH_TELEMETRY_ENDPOINT,
+ expect.objectContaining({
+ method: 'POST',
+ keepalive: true,
+ }),
+ );
+ fetchSpy.mockRestore();
+ });
});