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) */} +
+
+ + + 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(); + }); });