+ {/* Titlebar */}
+
+
+
+
+
STELLARFEES โ DASHBOARD
+
+
+ LIVE
+
+
+
+ {/* Stat cards */}
+
+ {STAT_CARDS.map((s, i) => (
+
+
{s.label}
+
+ {s.val}
+
+
+ ))}
+
+
+ {/* Chart */}
+
+
+
FEE HISTORY ยท 1H
+
+ {['1H', '6H', '24H'].map((w, i) => (
+ {w}
+ ))}
+
+
+
+ {BARS.map((h, i) => (
+
0.85 ? 'linear-gradient(to top, #ff4d6d60, #ff4d6d30)'
+ : h > 0.6 ? 'linear-gradient(to top, #ffd23f60, #ffd23f20)'
+ : 'linear-gradient(to top, #00ff9d60, #00ff9d20)',
+ border: `1px solid ${h > 0.85 ? '#ff4d6d30' : h > 0.6 ? '#ffd23f20' : '#00ff9d20'}`,
+ }} />
+ ))}
+
+
+ 01:18
+ 01:38
+
+
+
+ {/* Bottom panels */}
+
+
+
TREND ANALYSIS
+
โ Normal
+ {['1H', '6H', '24H'].map(l => (
+
+ {l} change
+ โ 0.0%
+
+ ))}
+
+
+
ROLLING AVERAGES
+ {ROLLING.map(({ l, v, c }) => (
+
+ {l}
+ {v}
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/app/components/landing/Nav.tsx b/packages/ui/src/app/components/landing/Nav.tsx
new file mode 100644
index 0000000..94ebc4a
--- /dev/null
+++ b/packages/ui/src/app/components/landing/Nav.tsx
@@ -0,0 +1,43 @@
+'use client'
+import Link from "next/link";
+import React from "react";
+
+function Nav() {
+ return (
+
+ );
+}
+
+export default Nav;
diff --git a/packages/ui/src/app/components/landing/OpenSourceCTA.tsx b/packages/ui/src/app/components/landing/OpenSourceCTA.tsx
new file mode 100644
index 0000000..e60c3c0
--- /dev/null
+++ b/packages/ui/src/app/components/landing/OpenSourceCTA.tsx
@@ -0,0 +1,61 @@
+'use client'
+import Link from 'next/link'
+
+const GITHUB = 'https://github.com/StellarCommons/stellar-fee-tracker'
+
+function GitHubIcon({ size = 14 }: { size?: number }) {
+ return (
+
+ )
+}
+
+export function OpenSourceCTA() {
+ return (
+
+
+ {/* Badge */}
+
+
+ Open Source ยท MIT License
+
+
+ {/* Headline */}
+
+ Free to use.
+ Free to fork.
+
+
+
+ Built with Rust, Next.js and Stellar's Horizon API.
+ Star it, fork it, contribute to it.
+
+
+ {/* CTAs */}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/app/components/landing/StatStrip.tsx b/packages/ui/src/app/components/landing/StatStrip.tsx
new file mode 100644
index 0000000..feb29dc
--- /dev/null
+++ b/packages/ui/src/app/components/landing/StatStrip.tsx
@@ -0,0 +1,97 @@
+'use client'
+
+import React, { RefObject, useEffect, useRef, useState } from "react";
+
+// โโโ Animated counter โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+function useCounter(target: number, duration = 1200) {
+ const [val, setVal] = useState(0);
+ const [started, setStarted] = useState(false);
+ const ref = useRef
(null);
+ useEffect(() => {
+ const obs = new IntersectionObserver(
+ ([e]) => {
+ if (e.isIntersecting) setStarted(true);
+ },
+ { threshold: 0.3 },
+ );
+ if (ref.current) obs.observe(ref.current);
+ return () => obs.disconnect();
+ }, []);
+ useEffect(() => {
+ if (!started) return;
+ let start: number;
+ const step = (ts: number) => {
+ if (!start) start = ts;
+ const p = Math.min((ts - start) / duration, 1);
+ setVal(Math.floor(p * target));
+ if (p < 1) requestAnimationFrame(step);
+ };
+ requestAnimationFrame(step);
+ }, [started, target, duration]);
+ return { val, ref };
+}
+
+function StatStrip() {
+ const { val: txCount, ref: txRef } = useCounter(10000);
+ const { val: ledgerCount, ref: ledgerRef } = useCounter(1351393);
+ const { val: pollRate, ref: pollRef } = useCounter(10);
+ return (
+
+
+ {[
+ {
+ label: "TRANSACTIONS TRACKED",
+ ref: txRef,
+ val: txCount,
+ suffix: "+",
+ color: "#00ff9d",
+ },
+ {
+ label: "LEDGERS PROCESSED",
+ ref: ledgerRef,
+ val: ledgerCount,
+ suffix: "",
+ color: "#00d4ff",
+ },
+ {
+ label: "POLL INTERVAL",
+ ref: pollRef,
+ val: pollRate,
+ suffix: "s",
+ color: "#ffd23f",
+ },
+ ].map(({ label, ref: r, val, suffix, color }) => (
+
}
+ className="flex-1 min-w-[160px]"
+ >
+
+ {label}
+
+
+ {val.toLocaleString()}
+ {suffix}
+
+
+ ))}
+
+
+ OPEN SOURCE
+
+
+ MIT
+
+
+
+
+ );
+}
+
+export default StatStrip;
diff --git a/packages/ui/src/app/components/landing/hooks/useCounter.ts b/packages/ui/src/app/components/landing/hooks/useCounter.ts
new file mode 100644
index 0000000..e69de29
diff --git a/packages/ui/src/app/components/landing/hooks/useTickingFee.ts b/packages/ui/src/app/components/landing/hooks/useTickingFee.ts
new file mode 100644
index 0000000..e69de29
diff --git a/packages/ui/src/app/components/landing/ui/Sparkline.tsx b/packages/ui/src/app/components/landing/ui/Sparkline.tsx
new file mode 100644
index 0000000..e69de29
diff --git a/packages/ui/src/app/dashboard/page.tsx b/packages/ui/src/app/dashboard/page.tsx
new file mode 100644
index 0000000..c67b50f
--- /dev/null
+++ b/packages/ui/src/app/dashboard/page.tsx
@@ -0,0 +1,31 @@
+import { api } from '@/lib/api'
+import { DashboardShell } from '@/components/dashboard/DashboardShell'
+
+export const dynamic = 'force-dynamic'
+export const revalidate = 0
+
+async function fetchDashboardData() {
+ try {
+ const [current, history, trend, insights] = await Promise.allSettled([
+ api.currentFees(),
+ api.feeHistory('1h'),
+ api.feeTrend(),
+ api.insights(),
+ ])
+
+ return {
+ current: current.status === 'fulfilled' ? current.value : null,
+ history: history.status === 'fulfilled' ? history.value : null,
+ trend: trend.status === 'fulfilled' ? trend.value : null,
+ insights: insights.status === 'fulfilled' ? insights.value : null,
+ error: null,
+ }
+ } catch (e) {
+ return { current: null, history: null, trend: null, insights: null, error: String(e) }
+ }
+}
+
+export default async function DashboardPage() {
+ const data = await fetchDashboardData()
+ return
+}
\ No newline at end of file
diff --git a/packages/ui/src/app/globals.css b/packages/ui/src/app/globals.css
index a2dc41e..2886d8a 100644
--- a/packages/ui/src/app/globals.css
+++ b/packages/ui/src/app/globals.css
@@ -1,26 +1,95 @@
+@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
@import "tailwindcss";
-:root {
- --background: #ffffff;
- --foreground: #171717;
-}
+@theme {
+ --color-bg-base: #080b0f;
+ --color-bg-surface: #0d1117;
+ --color-bg-card: #111820;
+ --color-bg-border: #1e2a36;
+
+ --color-accent-green: #00ff9d;
+ --color-accent-cyan: #00d4ff;
+ --color-accent-yellow: #ffd23f;
+ --color-accent-red: #ff4d6d;
+ --color-accent-dim: #1a3a2a;
+
+ --color-text-primary: #e2eaf4;
+ --color-text-secondary: #7a9ab5;
+ --color-text-muted: #3d5a73;
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ --font-mono: 'JetBrains Mono', monospace;
+ --font-display: 'Space Mono', monospace;
+
+ --animate-slide-up: slideUp 0.4s ease-out forwards;
+ --animate-fade-in: fadeIn 0.6s ease-out forwards;
+ --animate-blink: blink 1.2s step-end infinite;
}
-@media (prefers-color-scheme: dark) {
- :root {
- --background: #0a0a0a;
- --foreground: #ededed;
- }
+@keyframes blink {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+@keyframes slideUp {
+ from { opacity: 0; transform: translateY(12px); }
+ to { opacity: 1; transform: translateY(0); }
}
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ background-color: #080b0f;
+ color: #e2eaf4;
+ font-family: 'JetBrains Mono', monospace;
+ -webkit-font-smoothing: antialiased;
+}
+
+::-webkit-scrollbar { width: 6px; height: 6px; }
+::-webkit-scrollbar-track { background: #0d1117; }
+::-webkit-scrollbar-thumb { background: #1e2a36; border-radius: 3px; }
+::-webkit-scrollbar-thumb:hover { background: #2d4056; }
+
+::selection { background: #00ff9d22; color: #00ff9d; }
+
+.scanlines::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background: repeating-linear-gradient(
+ 0deg, transparent, transparent 2px,
+ rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px
+ );
+ z-index: 9999;
+}
+
+.grid-bg {
+ background-image:
+ linear-gradient(rgba(0,255,157,0.03) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(0,255,157,0.03) 1px, transparent 1px);
+ background-size: 40px 40px;
+}
+
+.glow-green { text-shadow: 0 0 20px rgba(0,255,157,0.5); }
+.glow-cyan { text-shadow: 0 0 20px rgba(0,212,255,0.5); }
+
+.card {
+ background: #111820;
+ border: 1px solid #1e2a36;
+ border-radius: 4px;
+}
+
+.ticker {
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.05em;
+}
+
+.recharts-cartesian-grid-horizontal line,
+.recharts-cartesian-grid-vertical line {
+ stroke: #1e2a36 !important;
}
+.recharts-tooltip-wrapper { outline: none !important; }
\ No newline at end of file
diff --git a/packages/ui/src/app/page.tsx b/packages/ui/src/app/page.tsx
index a932894..54da78d 100644
--- a/packages/ui/src/app/page.tsx
+++ b/packages/ui/src/app/page.tsx
@@ -1,103 +1,78 @@
-import Image from "next/image";
+"use client";
-export default function Home() {
+import Nav from "./components/landing/Nav";
+import Hero from "./components/landing/Hero";
+import StatStrip from "./components/landing/StatStrip";
+import MockDashboard from "./components/landing/MockDashboard";
+import { FeatureOrbit } from "./components/landing/FeatureOrbit";
+import { HowItWorks } from "./components/landing/HowItWorks";
+import { OpenSourceCTA } from "./components/landing/OpenSourceCTA";
+import { Footer } from "./components/landing/Footer";
+import { GridBeams } from "./components/landing/GridBeams";
+
+export default function LandingPage() {
return (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.tsx
-
- .
-
- -
- Save and see your changes instantly.
-
-
+
+ {/* Scanline texture */}
+
+
+ {/* Grid bg */}
+
+
-
-
-
- Deploy now
-
-
- Read our docs
-
+
+
+
+
+ {/* Dashboard Preview */}
+
+
+
+
+ DASHBOARD PREVIEW
+
+
+ Everything in one view.
+
+
+ From base fee to p99 distribution โ the full picture of
+ what's happening on the Stellar network right now.
+
+
+
-
-
+
+
+
+
+
+
);
}
diff --git a/packages/ui/src/components/dashboard/DashboardShell.tsx b/packages/ui/src/components/dashboard/DashboardShell.tsx
new file mode 100644
index 0000000..42dc705
--- /dev/null
+++ b/packages/ui/src/components/dashboard/DashboardShell.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { api } from '@/lib/api'
+import type {
+ CurrentFeeResponse,
+ FeeHistoryResponse,
+ FeeTrendResponse,
+ InsightsResponse,
+} from '@/lib/types'
+import { TopBar } from './TopBar'
+import { StatCards } from './StatCards'
+import { PercentileRow } from './PercentileRow'
+import { FeeChart } from './FeeChart'
+import { TrendPanel } from './TrendPanel'
+import { RollingAverages } from './RollingAverages'
+
+interface DashboardData {
+ current: CurrentFeeResponse | null
+ history: FeeHistoryResponse | null
+ trend: FeeTrendResponse | null
+ insights: InsightsResponse | null
+ error: string | null
+}
+
+interface Props {
+ initialData: DashboardData
+}
+
+const POLL_MS = 10_000
+
+export function DashboardShell({ initialData }: Props) {
+ const [data, setData] = useState
(initialData)
+ const [window, setWindow] = useState<'1h' | '6h' | '24h'>('1h')
+ const [lastUpdated, setLastUpdated] = useState(new Date())
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const [tick, setTick] = useState(0)
+
+ const refresh = useCallback(async (win = window) => {
+ setIsRefreshing(true)
+ try {
+ const [current, history, trend, insights] = await Promise.allSettled([
+ api.currentFees(),
+ api.feeHistory(win),
+ api.feeTrend(),
+ api.insights(),
+ ])
+ setData({
+ current: current.status === 'fulfilled' ? current.value : data.current,
+ history: history.status === 'fulfilled' ? history.value : data.history,
+ trend: trend.status === 'fulfilled' ? trend.value : data.trend,
+ insights: insights.status === 'fulfilled' ? insights.value : data.insights,
+ error: null,
+ })
+ setLastUpdated(new Date())
+ setTick(t => t + 1)
+ } catch {
+ // keep stale data
+ } finally {
+ setIsRefreshing(false)
+ }
+ }, [window, data])
+
+ // Auto-poll every 10s
+ useEffect(() => {
+ const id = setInterval(() => refresh(), POLL_MS)
+ return () => clearInterval(id)
+ }, [refresh])
+
+ // Re-fetch history when window changes
+ const handleWindowChange = async (w: '1h' | '6h' | '24h') => {
+ setWindow(w)
+ refresh(w)
+ }
+
+ const { current, history, trend, insights } = data
+
+ return (
+
+
refresh()}
+ />
+
+
+
+ {/* Row 1 โ Stat Cards */}
+
+
+ {/* Row 2 โ Percentile strip */}
+ {current && (
+
+ )}
+
+ {/* Row 3 โ Chart */}
+
+
+ {/* Row 4 โ Trend + Averages */}
+
+
+
+
+
+ {/* Footer */}
+
+ โ
+ {' '}STELLAR TESTNET ยท POLLING EVERY 10s ยท BUILT WITH RUST + NEXT.JS
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/FeeChart.tsx b/packages/ui/src/components/dashboard/FeeChart.tsx
new file mode 100644
index 0000000..e5f081c
--- /dev/null
+++ b/packages/ui/src/components/dashboard/FeeChart.tsx
@@ -0,0 +1,195 @@
+"use client";
+
+import {
+ ResponsiveContainer,
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ReferenceLine,
+} from "recharts";
+import type { FeeHistoryResponse, FeeDataPoint } from "@/lib/types";
+import { cn } from "@/lib/utils";
+
+interface Props {
+ history: FeeHistoryResponse | null;
+ window: "1h" | "6h" | "24h";
+ onWindowChange: (w: "1h" | "6h" | "24h") => void;
+}
+
+const WINDOWS: ("1h" | "6h" | "24h")[] = ["1h", "6h", "24h"];
+
+function formatTime(iso: string, win: string): string {
+ const d = new Date(iso);
+ if (win === "24h") {
+ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ }
+ return d.toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+}
+
+interface CustomTooltipProps {
+ active?: boolean;
+ payload?: Array<{ value: number }>;
+ label?: string;
+}
+
+function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
+ if (!active || !payload?.length) return null;
+ const fee = payload[0]?.value as number;
+ return (
+
+
{label}
+
+ {fee?.toLocaleString()} stroops
+
+
+ );
+}
+
+export function FeeChart({ history, window, onWindowChange }: Props) {
+ const points = history?.fees ?? [];
+
+ // Downsample for readability if too many points
+ const maxPoints = 200;
+ const step =
+ points.length > maxPoints ? Math.ceil(points.length / maxPoints) : 1;
+ const chartData = points
+ .filter((_, i) => i % step === 0)
+ .map((p: FeeDataPoint) => ({
+ time: formatTime(p.timestamp, window),
+ fee: p.fee_amount,
+ ledger: p.ledger_sequence,
+ }));
+
+ const avg = history?.summary.avg ?? 0;
+ const p95 = history?.summary.p95 ?? 0;
+ const count = history?.data_points ?? 0;
+
+ return (
+
+ {/* Header */}
+
+
+
+ Fee History
+
+ {count > 0 && (
+
+ {count.toLocaleString()} transactions
+ ยท avg
+ {avg.toFixed(0)} str
+ ยท p95
+
+ {p95.toLocaleString()} str
+
+
+ )}
+
+
+ {/* Window toggle */}
+
+ {WINDOWS.map((w) => (
+
+ ))}
+
+
+
+ {/* Chart */}
+ {chartData.length === 0 ? (
+
+ No data yet โ waiting for first poll cycle...
+
+ ) : (
+
+
+
+
+ `${v.toLocaleString()}`}
+ width={70}
+ />
+ } />
+ {avg > 0 && (
+
+ )}
+ {p95 > 0 && (
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/dashboard/PercentileRow.tsx b/packages/ui/src/components/dashboard/PercentileRow.tsx
new file mode 100644
index 0000000..c3f2883
--- /dev/null
+++ b/packages/ui/src/components/dashboard/PercentileRow.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+import type { PercentileFees } from '@/lib/types'
+import { formatStroops } from '@/lib/utils'
+
+interface Props {
+ percentiles: PercentileFees
+ tick: number
+}
+
+const LABELS: { key: keyof PercentileFees; label: string; color: string }[] = [
+ { key: 'p10', label: 'P10', color: 'text-accent-green border-accent-green/40' },
+ { key: 'p20', label: 'P20', color: 'text-accent-green border-accent-green/30' },
+ { key: 'p30', label: 'P30', color: 'text-accent-green border-accent-green/20' },
+ { key: 'p40', label: 'P40', color: 'text-accent-cyan border-accent-cyan/30' },
+ { key: 'p50', label: 'P50', color: 'text-accent-cyan border-accent-cyan/40' },
+ { key: 'p60', label: 'P60', color: 'text-accent-cyan border-accent-cyan/30' },
+ { key: 'p70', label: 'P70', color: 'text-accent-yellow border-accent-yellow/30' },
+ { key: 'p80', label: 'P80', color: 'text-accent-yellow border-accent-yellow/40' },
+ { key: 'p90', label: 'P90', color: 'text-accent-red border-accent-red/30' },
+ { key: 'p95', label: 'P95', color: 'text-accent-red border-accent-red/50' },
+ { key: 'p99', label: 'P99', color: 'text-accent-red border-accent-red/70' },
+]
+
+export function PercentileRow({ percentiles, tick }: Props) {
+ return (
+
+
+ Fee Percentiles ยท Last 5 Ledgers
+
+
+ {LABELS.map(({ key, label, color }) => (
+
+ {label}
+
+ {formatStroops(percentiles[key])}
+
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/RollingAverages.tsx b/packages/ui/src/components/dashboard/RollingAverages.tsx
new file mode 100644
index 0000000..e44e6bc
--- /dev/null
+++ b/packages/ui/src/components/dashboard/RollingAverages.tsx
@@ -0,0 +1,99 @@
+'use client'
+
+import type { InsightsResponse } from '@/lib/types'
+import { formatStroops, timeAgo, cn } from '@/lib/utils'
+
+interface Props {
+ insights: InsightsResponse | null
+ tick: number
+}
+
+export function RollingAverages({ insights, tick }: Props) {
+ if (!insights) {
+ return (
+
+ Loading insights...
+
+ )
+ }
+
+ const { rolling_averages, extremes } = insights
+
+ const rows = [
+ {
+ label: 'Short-term avg',
+ sub: '~5 min window',
+ result: rolling_averages.short_term,
+ accent: 'text-accent-green',
+ border: 'border-accent-green/20',
+ },
+ {
+ label: 'Medium-term avg',
+ sub: '~1 hour window',
+ result: rolling_averages.medium_term,
+ accent: 'text-accent-cyan',
+ border: 'border-accent-cyan/20',
+ },
+ {
+ label: 'Long-term avg',
+ sub: '~24 hour window',
+ result: rolling_averages.long_term,
+ accent: 'text-accent-yellow',
+ border: 'border-accent-yellow/20',
+ },
+ ]
+
+ return (
+
+
+ Rolling Averages
+
+
+
+ {rows.map(({ label, sub, result, accent, border }) => (
+
+
+
{label}
+
+ {sub}
+ {result.is_partial && (partial)}
+
+
+
+
+ {formatStroops(result.value)}
+
+
+ {result.sample_count} samples
+
+
+
+ ))}
+
+
+ {/* Extremes */}
+
+
+ Period Extremes
+
+
+
+ Min
+
+ {formatStroops(extremes.current_min.value)}
+
+
+
+ Max
+
+ {formatStroops(extremes.current_max.value)}
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/StatCards.tsx b/packages/ui/src/components/dashboard/StatCards.tsx
new file mode 100644
index 0000000..2026ec1
--- /dev/null
+++ b/packages/ui/src/components/dashboard/StatCards.tsx
@@ -0,0 +1,97 @@
+'use client'
+
+import { TrendingUp, TrendingDown, Minus, AlertTriangle, Layers, BarChart2 } from 'lucide-react'
+import type { CurrentFeeResponse, FeeTrendResponse } from '@/lib/types'
+import { formatStroops, congestionColor, congestionBg, cn } from '@/lib/utils'
+
+interface Props {
+ current: CurrentFeeResponse | null
+ trend: FeeTrendResponse | null
+ tick: number
+}
+
+function StatCard({
+ label,
+ value,
+ sub,
+ icon: Icon,
+ accent = false,
+ className = '',
+}: {
+ label: string
+ value: string
+ sub?: React.ReactNode
+ icon: React.ElementType
+ accent?: boolean
+ className?: string
+}) {
+ return (
+
+
+ {label}
+
+
+
+ {value}
+
+ {sub && (
+
{sub}
+ )}
+
+ )
+}
+
+export function StatCards({ current, trend, tick }: Props) {
+ const baseFee = current ? formatStroops(current.base_fee) : 'โ'
+ const avgFee = current ? formatStroops(current.avg_fee) : 'โ'
+ const status = trend?.status ?? 'Normal'
+ const spikes = trend?.recent_spike_count ?? 0
+ const strength = trend?.trend_strength ?? 'โ'
+
+ const TrendIcon = status === 'Rising' || status === 'Congested'
+ ? TrendingUp
+ : status === 'Declining'
+ ? TrendingDown
+ : Minus
+
+ return (
+
+ last ledger}
+ icon={Layers}
+ accent
+ />
+ mode over 5 ledgers}
+ icon={BarChart2}
+ />
+ {strength} trend}
+ icon={TrendIcon}
+ className={congestionBg(status)}
+ />
+ 0
+ ? fee anomalies detected
+ : all clear
+ }
+ icon={AlertTriangle}
+ />
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/TopBar.tsx b/packages/ui/src/components/dashboard/TopBar.tsx
new file mode 100644
index 0000000..c924c80
--- /dev/null
+++ b/packages/ui/src/components/dashboard/TopBar.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { RefreshCw, Activity, Zap } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+interface Props {
+ lastUpdated: Date
+ isRefreshing: boolean
+ onRefresh: () => void
+}
+
+export function TopBar({ lastUpdated, isRefreshing, onRefresh }: Props) {
+ const timeStr = lastUpdated.toLocaleTimeString([], {
+ hour: '2-digit', minute: '2-digit', second: '2-digit'
+ })
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/dashboard/TrendPanel.tsx b/packages/ui/src/components/dashboard/TrendPanel.tsx
new file mode 100644
index 0000000..1e3ec2f
--- /dev/null
+++ b/packages/ui/src/components/dashboard/TrendPanel.tsx
@@ -0,0 +1,89 @@
+'use client'
+
+import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
+import type { FeeTrendResponse } from '@/lib/types'
+import { pctColor, pctArrow, congestionColor, cn } from '@/lib/utils'
+
+interface Props {
+ trend: FeeTrendResponse | null
+ tick: number
+}
+
+export function TrendPanel({ trend, tick }: Props) {
+ if (!trend) {
+ return (
+
+ Loading trend data...
+
+ )
+ }
+
+ const { status, trend_strength, changes, recent_spike_count, predicted_congestion_minutes } = trend
+
+ const StatusIcon =
+ status === 'Rising' || status === 'Congested' ? TrendingUp :
+ status === 'Declining' ? TrendingDown : Minus
+
+ const strengthDots = trend_strength === 'Strong' ? 3 : trend_strength === 'Moderate' ? 2 : 1
+
+ return (
+
+
+ Trend Analysis
+
+
+ {/* Status + strength */}
+
+
+
+
+ {status}
+
+
+
+ {[1, 2, 3].map(i => (
+
+ ))}
+
{trend_strength}
+
+
+
+ {/* Pct changes */}
+
+ {([
+ ['1H', changes['1h_pct']],
+ ['6H', changes['6h_pct']],
+ ['24H', changes['24h_pct']],
+ ] as [string, number | null][]).map(([label, pct]) => (
+
+ {label} change
+
+ {pctArrow(pct)}
+
+
+ ))}
+
+
+ {/* Extras */}
+
+ Recent spikes
+ 0 ? 'text-accent-yellow' : 'text-accent-green'}>
+ {recent_spike_count}
+
+
+
+ {predicted_congestion_minutes !== null && (
+
+ Predicted congestion
+ {predicted_congestion_minutes}m
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts
new file mode 100644
index 0000000..b89bd50
--- /dev/null
+++ b/packages/ui/src/lib/api.ts
@@ -0,0 +1,27 @@
+import type {
+ CurrentFeeResponse,
+ FeeHistoryResponse,
+ FeeTrendResponse,
+ InsightsResponse,
+ HealthResponse,
+} from './types'
+
+const BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080'
+
+async function get(path: string): Promise {
+ const res = await fetch(`${BASE}${path}`, {
+ next: { revalidate: 0 }, // always fresh
+ })
+ if (!res.ok) {
+ throw new Error(`API error ${res.status} on ${path}`)
+ }
+ return res.json() as Promise
+}
+
+export const api = {
+ currentFees: () => get('/fees/current'),
+ feeHistory: (window = '1h') => get(`/fees/history?window=${window}`),
+ feeTrend: () => get('/fees/trend'),
+ insights: () => get('/insights'),
+ health: () => get('/health'),
+}
\ No newline at end of file
diff --git a/packages/ui/src/lib/types.ts b/packages/ui/src/lib/types.ts
new file mode 100644
index 0000000..e548da2
--- /dev/null
+++ b/packages/ui/src/lib/types.ts
@@ -0,0 +1,103 @@
+// ---- /fees/current ----
+export interface PercentileFees {
+ p10: string
+ p20: string
+ p30: string
+ p40: string
+ p50: string
+ p60: string
+ p70: string
+ p80: string
+ p90: string
+ p95: string
+ p99: string
+}
+
+export interface CurrentFeeResponse {
+ base_fee: string
+ min_fee: string
+ max_fee: string
+ avg_fee: string
+ percentiles: PercentileFees
+}
+
+// ---- /fees/history ----
+export interface FeeDataPoint {
+ fee_amount: number
+ timestamp: string
+ transaction_hash: string
+ ledger_sequence: number
+}
+
+export interface FeeSummary {
+ min: number
+ max: number
+ avg: number
+ p50: number
+ p95: number
+}
+
+export interface FeeHistoryResponse {
+ window: string
+ from: string
+ to: string
+ data_points: number
+ fees: FeeDataPoint[]
+ summary: FeeSummary
+}
+
+// ---- /fees/trend ----
+export interface TrendChanges {
+ '1h_pct': number | null
+ '6h_pct': number | null
+ '24h_pct': number | null
+}
+
+export interface FeeTrendResponse {
+ status: 'Normal' | 'Rising' | 'Congested' | 'Declining'
+ trend_strength: 'Weak' | 'Moderate' | 'Strong'
+ changes: TrendChanges
+ recent_spike_count: number
+ predicted_congestion_minutes: number | null
+ last_updated: string
+}
+
+// ---- /insights ----
+export interface AverageResult {
+ value: number
+ sample_count: number
+ is_partial: boolean
+ calculated_at: string
+}
+
+export interface RollingAverages {
+ short_term: AverageResult
+ medium_term: AverageResult
+ long_term: AverageResult
+}
+
+export interface ExtremeValue {
+ value: number
+ timestamp: string
+ transaction_hash: string
+}
+
+export interface FeeExtremes {
+ current_min: ExtremeValue
+ current_max: ExtremeValue
+ period_start: string
+ period_end: string
+}
+
+export interface InsightsResponse {
+ rolling_averages: RollingAverages
+ extremes: FeeExtremes
+ last_updated: string
+ // these exist in the response but we can ignore them for now
+ congestion_trends?: unknown
+ data_quality?: unknown
+}
+// ---- /health ----
+export interface HealthResponse {
+ status: string
+}
\ No newline at end of file
diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts
new file mode 100644
index 0000000..7f01731
--- /dev/null
+++ b/packages/ui/src/lib/utils.ts
@@ -0,0 +1,63 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
+
+export function formatStroops(stroops: number | string): string {
+ const n = typeof stroops === 'string' ? parseFloat(stroops) : stroops
+ if (n >= 10_000_000) return `${(n / 10_000_000).toFixed(2)} XLM`
+ if (n >= 1_000) return `${n.toLocaleString()} str`
+ return `${n} str`
+}
+
+export function formatNumber(n: number): string {
+ return n.toLocaleString()
+}
+
+export function pctColor(pct: number | null): string {
+ if (pct === null) return 'text-text-secondary'
+ if (pct > 10) return 'text-accent-red'
+ if (pct > 0) return 'text-accent-yellow'
+ if (pct < -10) return 'text-accent-green'
+ if (pct < 0) return 'text-accent-cyan'
+ return 'text-text-secondary'
+}
+
+export function pctArrow(pct: number | null): string {
+ if (pct === null) return 'โ'
+ if (pct > 0) return `โฒ +${pct.toFixed(1)}%`
+ if (pct < 0) return `โผ ${pct.toFixed(1)}%`
+ return `โ 0.0%`
+}
+
+export function congestionColor(status: string): string {
+ switch (status) {
+ case 'Congested': return 'text-accent-red'
+ case 'Rising': return 'text-accent-yellow'
+ case 'Declining': return 'text-accent-cyan'
+ default: return 'text-accent-green'
+ }
+}
+
+export function congestionBg(status: string): string {
+ switch (status) {
+ case 'Congested': return 'bg-red-950/30 border-accent-red/30'
+ case 'Rising': return 'bg-yellow-950/30 border-accent-yellow/30'
+ case 'Declining': return 'bg-cyan-950/30 border-accent-cyan/30'
+ default: return 'bg-accent-dim border-accent-green/30'
+ }
+}
+
+export function timeAgo(iso: string): string {
+ const diff = Date.now() - new Date(iso).getTime()
+ const s = Math.floor(diff / 1000)
+ if (s < 60) return `${s}s ago`
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`
+ return `${Math.floor(s / 3600)}h ago`
+}
+
+export function formatTime(iso: string): string {
+ return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
\ No newline at end of file