diff --git a/packages/ui/src/app/app/page.tsx b/packages/ui/src/app/app/page.tsx new file mode 100644 index 0000000..cf595d3 --- /dev/null +++ b/packages/ui/src/app/app/page.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useState } from "react"; +import { fetchTransaction, fetchAccount, getErrorMessage } from "@/lib/api"; +import type { TransactionExplanation, AccountExplanation, Tab } from "@/types"; +import { TabSwitcher } from "@/components/TabSwitcher"; +import { SearchBar } from "@/components/SearchBar"; +import { TransactionResult } from "@/components/TransactionResult"; +import { AccountResult } from "@/components/AccountResult"; + +export default function Home() { + const [tab, setTab] = useState("tx"); + + // Each tab has its own independent input + result — switching tabs + // preserves whatever was last searched on each side. + const [txInput, setTxInput] = useState(""); + const [accountInput, setAccountInput] = useState(""); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [txResult, setTxResult] = useState(null); + const [accountResult, setAccountResult] = useState( + null, + ); + + const input = tab === "tx" ? txInput : accountInput; + const setInput = tab === "tx" ? setTxInput : setAccountInput; + + async function handleExplain() { + const trimmed = input.trim(); + if (!trimmed) return; + + setLoading(true); + setError(null); + + try { + if (tab === "tx") { + setTxResult(null); + const data = await fetchTransaction(trimmed); + setTxResult(data); + } else { + setAccountResult(null); + const data = await fetchAccount(trimmed); + setAccountResult(data); + } + } catch (err) { + setError(getErrorMessage(err)); + } finally { + setLoading(false); + } + } + + const hasResult = tab === "tx" ? !!txResult : !!accountResult; + + return ( +
+ {/* grid background */} +
+ + {/* horizon glow */} +
+ +
+ {/* header */} +
+
+
+ + + + +
+

+ Stellar Explain +

+
+

+ Plain-English explanations for Stellar blockchain operations. +

+
+ + + + + + {/* error */} + {error && ( +
+ {error} +
+ )} + + {/* results */} + {tab === "tx" && txResult && } + {tab === "account" && accountResult && ( + + )} + + {/* empty state */} + {!hasResult && !error && !loading && ( +
+

+ {tab === "tx" + ? "enter a transaction hash to decode it" + : "enter an account address to inspect it"} +

+
+ )} +
+
+ ); +} diff --git a/packages/ui/src/app/globals.css b/packages/ui/src/app/globals.css index a2dc41e..cd6eef6 100644 --- a/packages/ui/src/app/globals.css +++ b/packages/ui/src/app/globals.css @@ -1,26 +1,34 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #080c12; + --foreground: #e8eaf0; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; } + +@keyframes animate-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.animate-in { + animation: animate-in 0.25s ease-out forwards; +} +@keyframes fadeSlideUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.animate-fadeSlideUp { + animation: fadeSlideUp 0.35s ease forwards; +} \ No newline at end of file diff --git a/packages/ui/src/app/layout.tsx b/packages/ui/src/app/layout.tsx index f7fa87e..c09caa4 100644 --- a/packages/ui/src/app/layout.tsx +++ b/packages/ui/src/app/layout.tsx @@ -1,20 +1,22 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const ibmPlexMono = IBM_Plex_Mono({ + variable: "--font-mono", subsets: ["latin"], + weight: ["400", "500", "600"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const ibmPlexSans = IBM_Plex_Sans({ + variable: "--font-sans", subsets: ["latin"], + weight: ["400", "500", "600"], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Stellar Explain", + description: "Plain-English explanations for Stellar blockchain operations.", }; export default function RootLayout({ @@ -24,11 +26,9 @@ export default function RootLayout({ }>) { return ( - + {children} ); -} +} \ No newline at end of file diff --git a/packages/ui/src/app/page.tsx b/packages/ui/src/app/page.tsx index a932894..ce5e836 100644 --- a/packages/ui/src/app/page.tsx +++ b/packages/ui/src/app/page.tsx @@ -1,103 +1,56 @@ -import Image from "next/image"; +'use client";' +import Navbar from "@/components/landing/Navbar"; +import HeroSection from "@/components/landing/HeroSection"; +import HowItWorksSection from "@/components/landing/HowItWorksSection"; +import WhatWeDecodeSection from "@/components/landing/WhatWeDecodeSection"; +import OpenSourceSection from "@/components/landing/OpenSourceSection"; +import Footer from "@/components/landing/Footer"; -export default function Home() { +export default function LandingPage() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
+
+ {/* ── Background layers ── */} +
+
+
+
-
); } diff --git a/packages/ui/src/components/AccountResult.tsx b/packages/ui/src/components/AccountResult.tsx new file mode 100644 index 0000000..bcc1f99 --- /dev/null +++ b/packages/ui/src/components/AccountResult.tsx @@ -0,0 +1,76 @@ +import type { AccountExplanation } from "@/types"; +import { Card } from "@/components/Card"; +import { Label } from "@/components/Label"; +import { Pill } from "@/components/Pill"; +import { formatBalance } from "@/lib/utils"; + +interface AccountResultProps { + data: AccountExplanation; +} + +export function AccountResult({ data }: AccountResultProps) { + return ( +
+ + {/* header */} +
+
+ +

+ {data.address} +

+
+ {data.org_name && ( + + )} +
+ + {/* summary */} + + +

{data.summary}

+
+ + {/* stats grid */} +
+ + +

+ {formatBalance(data.xlm_balance)} +

+

XLM

+
+ + +

{data.asset_count}

+

trust lines

+
+ + +

{data.signer_count}

+

keys

+
+
+ + {/* home domain */} + {data.home_domain && ( + + +

{data.home_domain}

+
+ )} + + {/* flags */} + {data.flag_descriptions.length > 0 && ( + + +
+ {data.flag_descriptions.map((flag, i) => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/components/AddressChip.tsx b/packages/ui/src/components/AddressChip.tsx new file mode 100644 index 0000000..37a9b7b --- /dev/null +++ b/packages/ui/src/components/AddressChip.tsx @@ -0,0 +1,16 @@ +import { shortAddr } from "@/lib/utils"; + +interface AddressChipProps { + addr: string; +} + +export function AddressChip({ addr }: AddressChipProps) { + return ( + + {shortAddr(addr)} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx new file mode 100644 index 0000000..3603ba5 --- /dev/null +++ b/packages/ui/src/components/Card.tsx @@ -0,0 +1,14 @@ +interface CardProps { + children: React.ReactNode; + className?: string; +} + +export function Card({ children, className = "" }: CardProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Label.tsx b/packages/ui/src/components/Label.tsx new file mode 100644 index 0000000..0ed428b --- /dev/null +++ b/packages/ui/src/components/Label.tsx @@ -0,0 +1,11 @@ +interface LabelProps { + children: React.ReactNode; +} + +export function Label({ children }: LabelProps) { + return ( +

+ {children} +

+ ); +} \ No newline at end of file diff --git a/packages/ui/src/components/Pill.tsx b/packages/ui/src/components/Pill.tsx new file mode 100644 index 0000000..d52c3ae --- /dev/null +++ b/packages/ui/src/components/Pill.tsx @@ -0,0 +1,23 @@ +import type { PillVariant } from "@/types"; + +interface PillProps { + label: string; + variant?: PillVariant; +} + +const variantClasses: Record = { + success: "bg-emerald-900/40 text-emerald-300 border-emerald-700/50", + fail: "bg-red-900/40 text-red-300 border-red-700/50", + warning: "bg-amber-900/40 text-amber-300 border-amber-700/50", + default: "bg-sky-900/40 text-sky-300 border-sky-700/50", +}; + +export function Pill({ label, variant = "default" }: PillProps) { + return ( + + {label} + + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/SearchBar.tsx b/packages/ui/src/components/SearchBar.tsx new file mode 100644 index 0000000..df4dd95 --- /dev/null +++ b/packages/ui/src/components/SearchBar.tsx @@ -0,0 +1,54 @@ +import type { Tab } from "@/types"; + +interface SearchBarProps { + tab: Tab; + value: string; + loading: boolean; + onChange: (value: string) => void; + onSubmit: () => void; +} + +const PLACEHOLDERS: Record = { + tx: "Paste a transaction hash…", + account: "Paste a Stellar account address (G…)", +}; + +export function SearchBar({ + tab, + value, + loading, + onChange, + onSubmit, +}: SearchBarProps) { + function handleKey(e: React.KeyboardEvent) { + if (e.key === "Enter") onSubmit(); + } + + return ( +
+ onChange(e.target.value)} + onKeyDown={handleKey} + placeholder={PLACEHOLDERS[tab]} + spellCheck={false} + className="flex-1 bg-white/4 border border-white/10 rounded-lg px-4 py-3 text-xs font-mono text-white/80 placeholder:text-white/20 outline-none focus:border-sky-500/50 focus:bg-white/5 transition-all" + /> + +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/components/TabSwitcher.tsx b/packages/ui/src/components/TabSwitcher.tsx new file mode 100644 index 0000000..2a7702a --- /dev/null +++ b/packages/ui/src/components/TabSwitcher.tsx @@ -0,0 +1,31 @@ +import type { Tab } from "@/types"; + +interface TabSwitcherProps { + active: Tab; + onChange: (tab: Tab) => void; +} + +const TABS: { id: Tab; label: string }[] = [ + { id: "tx", label: "Transaction" }, + { id: "account", label: "Account" }, +]; + +export function TabSwitcher({ active, onChange }: TabSwitcherProps) { + return ( +
+ {TABS.map((t) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/components/TransactionResult.tsx b/packages/ui/src/components/TransactionResult.tsx new file mode 100644 index 0000000..ff962cd --- /dev/null +++ b/packages/ui/src/components/TransactionResult.tsx @@ -0,0 +1,112 @@ +import type { TransactionExplanation } from "@/types"; +import { Card } from "@/components/Card"; +import { Label } from "@/components/Label"; +import { Pill } from "@/components/Pill"; +import { AddressChip } from "@/components/AddressChip"; +import { formatLedgerTime } from "@/lib/utils"; + +interface TransactionResultProps { + data: TransactionExplanation; +} + +export function TransactionResult({ data }: TransactionResultProps) { + return ( +
+ + {/* header */} +
+
+ +

+ {data.transaction_hash} +

+
+
+ + {data.skipped_operations > 0 && ( + + )} +
+
+ + {/* summary */} + + +

{data.summary}

+
+ + {/* timeline */} + {(data.ledger_closed_at || data.ledger) && ( + + {data.ledger_closed_at && ( +
+ +

+ {formatLedgerTime(data.ledger_closed_at)} +

+
+ )} + {data.ledger && ( +
+ +

+ #{data.ledger.toLocaleString()} +

+
+ )} +
+ )} + + {/* memo */} + {data.memo_explanation && ( + + +

{data.memo_explanation}

+
+ )} + + {/* fee */} + {data.fee_explanation && ( + + +

{data.fee_explanation}

+
+ )} + + {/* payments */} + {data.payment_explanations.length > 0 && ( +
+ + {data.payment_explanations.map((p, i) => ( + +

{p.summary}

+
+
+ + +
+
+ + +
+
+ + + {p.amount}{" "} + {p.asset} + +
+
+
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/components/landing/AnimatedDemo.tsx b/packages/ui/src/components/landing/AnimatedDemo.tsx new file mode 100644 index 0000000..a952935 --- /dev/null +++ b/packages/ui/src/components/landing/AnimatedDemo.tsx @@ -0,0 +1,479 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +// ── Demo data ───────────────────────────────────────────────────────────── + +const DEMO_TX_HASH = "3389e9f0f1a65f19736cacf544c2e825313e8447f569233bb8db39aa607c8889"; +const DEMO_ACCOUNT = "GCCD6AJOYZCUAQLX32ZJF2MKFFAUJ53PVCFQI3RHWKL3V47ONVKA62KST"; + +const DEMO_TX = { + hash: "3389e9f0...07c8889", + summary: "This successful transaction contains 1 payment. GABC…XYZ sent 250.00 USDC to GCJ2…TYBE.", + confirmedAt: "2026-02-18 08:02:05 UTC", + ledger: "#61,482,880", + payment: { from: "GABC…3DE5", to: "GCJ2…HV6J", amount: "250.00 USDC" }, +}; + +const DEMO_ACCT = { + address: "GCCD6AJO...A62KST", + summary: "This account holds 9,842.50 XLM and 3 other assets. It has 1 signer.", + xlm: "9,842.50", + assets: "3", + signers: "1", +}; + +function delay(ms: number) { + return new Promise((res) => setTimeout(res, ms)); +} + +type Phase = "idle" | "typing" | "loading" | "result" | "exiting"; +type ActiveTab = "tx" | "account"; + +// ── Card — animates in or out based on prop ─────────────────────────────── + +function DemoCard({ + children, + animateOut, + exitDelay = 0, +}: { + children: React.ReactNode; + animateOut: boolean; + exitDelay?: number; +}) { + return ( +
+ {children} +
+ ); +} + +// ── Component ───────────────────────────────────────────────────────────── + +export default function AnimatedDemo() { + const [activeTab, setActiveTab] = useState("tx"); + const [tabSliding, setTabSliding] = useState(false); + const [phase, setPhase] = useState("idle"); + const [typedText, setTypedText] = useState(""); + const [visibleCards, setVisibleCards] = useState(0); + const cancelRef = useRef(false); + const timerRef = useRef | null>(null); + + // number of cards shown for current tab + const TX_CARD_COUNT = 4; + const ACCT_CARD_COUNT = 3; + + useEffect(() => { + cancelRef.current = false; + + async function runTxDemo() { + if (cancelRef.current) return; + setActiveTab("tx"); + setPhase("idle"); + setTypedText(""); + setVisibleCards(0); + + await delay(800); + if (cancelRef.current) return; + + // type hash + setPhase("typing"); + for (let i = 1; i <= DEMO_TX_HASH.length; i++) { + await delay(22); + if (cancelRef.current) return; + setTypedText(DEMO_TX_HASH.slice(0, i)); + } + + await delay(350); + if (cancelRef.current) return; + + setPhase("loading"); + await delay(1200); + if (cancelRef.current) return; + + // cards animate in top → bottom + setPhase("result"); + setVisibleCards(0); + for (let i = 1; i <= TX_CARD_COUNT; i++) { + await delay(200); + if (cancelRef.current) return; + setVisibleCards(i); + } + + // hold + await delay(3000); + if (cancelRef.current) return; + + // cards animate out bottom → top + setPhase("exiting"); + await delay(TX_CARD_COUNT * 80 + 300); + if (cancelRef.current) return; + + await runAccountDemo(); + } + + async function runAccountDemo() { + if (cancelRef.current) return; + + // tab slides + setTabSliding(true); + await delay(180); + if (cancelRef.current) return; + setActiveTab("account"); + setTabSliding(false); + + setPhase("idle"); + setTypedText(""); + setVisibleCards(0); + + await delay(600); + if (cancelRef.current) return; + + setPhase("typing"); + for (let i = 1; i <= DEMO_ACCOUNT.length; i++) { + await delay(20); + if (cancelRef.current) return; + setTypedText(DEMO_ACCOUNT.slice(0, i)); + } + + await delay(350); + if (cancelRef.current) return; + + setPhase("loading"); + await delay(1200); + if (cancelRef.current) return; + + // cards animate in + setPhase("result"); + setVisibleCards(0); + for (let i = 1; i <= ACCT_CARD_COUNT; i++) { + await delay(200); + if (cancelRef.current) return; + setVisibleCards(i); + } + + // hold + await delay(3000); + if (cancelRef.current) return; + + // cards animate out + setPhase("exiting"); + await delay(ACCT_CARD_COUNT * 80 + 300); + if (cancelRef.current) return; + + timerRef.current = setTimeout(runTxDemo, 400); + } + + timerRef.current = setTimeout(runTxDemo, 400); + return () => { + cancelRef.current = true; + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + const isExiting = phase === "exiting"; + const showCards = phase === "result" || phase === "exiting"; + const placeholder = + activeTab === "tx" ? "Paste a transaction hash…" : "Paste a Stellar address (G…)"; + + // exit delay per card index — bottom card exits first (reverse order) + const txExitDelay = (cardIndex: number) => + (TX_CARD_COUNT - cardIndex) * 70; + const acctExitDelay = (cardIndex: number) => + (ACCT_CARD_COUNT - cardIndex) * 70; + + return ( + <> + {/* keyframes injected once */} + + + {/* outer wrapper — hard constrained to its column */} +
+ {/* glow */} +
+ + {/* window — full width of its container, never wider */} +
+ {/* chrome */} +
+
+
+
+ + stellar-explain.app + +
+ + {/* body */} +
+ + {/* tabs */} +
+ {(["tx", "account"] as ActiveTab[]).map((t) => { + const isActive = activeTab === t; + return ( +
+ {t === "tx" ? "Transaction" : "Account"} +
+ ); + })} +
+ + {/* input row — input shrinks, button is rigid */} +
+
+ + {phase === "idle" ? placeholder : typedText} + {phase === "typing" && ( + + )} + +
+ + {/* button — never shrinks */} +
+ {phase === "loading" ? ( + + ) : ( + "Explain →" + )} +
+
+ + {/* results */} + {showCards && ( +
+ + {/* ── TX cards ── */} + {activeTab === "tx" && ( + <> + {visibleCards >= 1 && ( + +
+ + {DEMO_TX.hash} + + + Confirmed + +
+
+ )} + + {visibleCards >= 2 && ( + +
+

Summary

+

{DEMO_TX.summary}

+
+
+ )} + + {visibleCards >= 3 && ( + +
+
+

Confirmed at

+

{DEMO_TX.confirmedAt}

+
+
+

Ledger

+

{DEMO_TX.ledger}

+
+
+
+ )} + + {visibleCards >= 4 && ( + +
+

Payment

+
+
+

From

+ {DEMO_TX.payment.from} +
+
+

To

+ {DEMO_TX.payment.to} +
+
+

Amount

+ {DEMO_TX.payment.amount} +
+
+
+
+ )} + + )} + + {/* ── Account cards ── */} + {activeTab === "account" && ( + <> + {visibleCards >= 1 && ( + +
+

Account

+

{DEMO_ACCT.address}

+
+
+ )} + + {visibleCards >= 2 && ( + +
+

Summary

+

{DEMO_ACCT.summary}

+
+
+ )} + + {visibleCards >= 3 && ( + +
+ {[ + { label: "XLM", value: DEMO_ACCT.xlm }, + { label: "Assets", value: DEMO_ACCT.assets }, + { label: "Signers", value: DEMO_ACCT.signers }, + ].map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+
+ )} + + )} + +
+ )} +
+
+
+ + ); +} \ No newline at end of file diff --git a/packages/ui/src/components/landing/Footer.tsx b/packages/ui/src/components/landing/Footer.tsx new file mode 100644 index 0000000..e5a07de --- /dev/null +++ b/packages/ui/src/components/landing/Footer.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; +import React from "react"; + +export default function Footer() { + return ( + + ); +} diff --git a/packages/ui/src/components/landing/HeroSection.tsx b/packages/ui/src/components/landing/HeroSection.tsx new file mode 100644 index 0000000..a01cbf9 --- /dev/null +++ b/packages/ui/src/components/landing/HeroSection.tsx @@ -0,0 +1,79 @@ +import Link from 'next/link' +import AnimatedDemo from './AnimatedDemo' + +function HeroSection() { + return ( +
+
+ + {/* left — copy */} +
+ {/* eyebrow */} +
+ + Stellar Mainnet · Live +
+ +
+

+ Stellar transactions, +
+ in plain English. +

+

+ Paste any transaction hash or account address. Get a clear, + human-readable explanation of exactly what happened — no + blockchain expertise required. +

+
+ +
+ + Try it now + + + + + + + + + View on GitHub + +
+ +

+ Open source · Built on Stellar Horizon · No login required +

+
+ + {/* right — animated demo, hard constrained to its column */} +
+ +
+ +
+
+ ) +} + +export default HeroSection \ No newline at end of file diff --git a/packages/ui/src/components/landing/HowItWorksSection.tsx b/packages/ui/src/components/landing/HowItWorksSection.tsx new file mode 100644 index 0000000..dfebe59 --- /dev/null +++ b/packages/ui/src/components/landing/HowItWorksSection.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { STEPS } from '@/lib/landing-data' + +function HowItWorksSection() { + return ( +
+
+

+ How it works +

+

+ Three steps to clarity +

+
+ +
+ {STEPS.map((step) => ( +
+ {/* step number watermark */} + + {step.n} + + +
+ {step.icon} +
+ +

+ {step.title} +

+

+ {step.body} +

+
+ ))} +
+
+ ) +} + +export default HowItWorksSection \ No newline at end of file diff --git a/packages/ui/src/components/landing/Navbar.tsx b/packages/ui/src/components/landing/Navbar.tsx new file mode 100644 index 0000000..47723e1 --- /dev/null +++ b/packages/ui/src/components/landing/Navbar.tsx @@ -0,0 +1,53 @@ +import Link from 'next/link' +import React from 'react' + +function Navbar() { + return ( + + ) +} + +export default Navbar \ No newline at end of file diff --git a/packages/ui/src/components/landing/OpenSourceSection.tsx b/packages/ui/src/components/landing/OpenSourceSection.tsx new file mode 100644 index 0000000..93d198e --- /dev/null +++ b/packages/ui/src/components/landing/OpenSourceSection.tsx @@ -0,0 +1,69 @@ +import React from "react"; + +export default function OpenSourceSection() { + return ( +
+
+ {/* inner glow */} +
+ +
+
+ + + + Open Source +
+ +

+ Built in the open. +
+ Come build with us. +

+ +

+ Stellar Explain is fully open source. Whether you want to add a new + operation explainer, improve the UI, or fix a bug — every + contribution makes Web3 more human readable. +

+ + +
+
+
+ ); +} diff --git a/packages/ui/src/components/landing/WhatWeDecodeSection.tsx b/packages/ui/src/components/landing/WhatWeDecodeSection.tsx new file mode 100644 index 0000000..b254f73 --- /dev/null +++ b/packages/ui/src/components/landing/WhatWeDecodeSection.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { FEATURES } from '@/lib/landing-data' + +function WhatWeDecodeSection() { + return ( +
+
+

+ Coverage +

+

+ What we decode +

+

+ Every major Stellar operation type, explained in context. +

+
+ +
+ {FEATURES.map((f) => ( +
+
+
+

+ {f.label} +

+

+ {f.desc} +

+
+
+ ))} +
+
+ ) +} + +export default WhatWeDecodeSection \ No newline at end of file diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index 46a2f39..2ca8ae9 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -5,65 +5,36 @@ * so the Rust backend URL never reaches the browser. */ -// ── Types ────────────────────────────────────────────────────────────────── +import type { + TransactionExplanation, + AccountExplanation, + HealthResponse, + ApiError, +} from "@/types"; -export interface PaymentExplanation { - summary: string; - from: string; - to: string; - asset: string; - amount: string; -} - -export interface TransactionExplanation { - transaction_hash: string; - successful: boolean; - summary: string; - payment_explanations: PaymentExplanation[]; - skipped_operations: number; - memo_explanation: string | null; - fee_explanation: string | null; -} - -export interface AccountExplanation { - address: string; - summary: string; - xlm_balance: string; - asset_count: number; - signer_count: number; - home_domain: string | null; - org_name: string | null; -} - -export interface HealthResponse { - status: string; - network: string; - horizon_reachable: boolean; - version: string; -} - -export interface ApiError { - error: { - code: string; - message: string; - }; -} +export type { + TransactionExplanation, + AccountExplanation, + HealthResponse, + ApiError, + PaymentExplanation, +} from "@/types"; // ── Helpers ──────────────────────────────────────────────────────────────── export function isApiError(value: unknown): value is ApiError { return ( - typeof value === 'object' && + typeof value === "object" && value !== null && - 'error' in value && - typeof (value as ApiError).error?.code === 'string' + "error" in value && + typeof (value as ApiError).error?.code === "string" ); } export function getErrorMessage(error: unknown): string { if (isApiError(error)) return error.error.message; if (error instanceof Error) return error.message; - return 'Something went wrong. Please try again.'; + return "Something went wrong. Please try again."; } async function handleResponse(res: Response): Promise { @@ -74,35 +45,27 @@ async function handleResponse(res: Response): Promise { // ── Fetch Functions ──────────────────────────────────────────────────────── -/** - * Fetch a transaction explanation by hash. - * Calls: GET /api/tx/:hash → proxied to Rust GET /tx/:hash - */ -export async function fetchTransaction(hash: string): Promise { +export async function fetchTransaction( + hash: string +): Promise { const res = await fetch(`/api/tx/${hash}`, { - headers: { Accept: 'application/json' }, + headers: { Accept: "application/json" }, }); return handleResponse(res); } -/** - * Fetch an account explanation by Stellar address. - * Calls: GET /api/account/:address → proxied to Rust GET /account/:address - */ -export async function fetchAccount(address: string): Promise { +export async function fetchAccount( + address: string +): Promise { const res = await fetch(`/api/account/${address}`, { - headers: { Accept: 'application/json' }, + headers: { Accept: "application/json" }, }); return handleResponse(res); } -/** - * Fetch backend health status. - * Calls: GET /api/health → proxied to Rust GET /health - */ export async function fetchHealth(): Promise { - const res = await fetch('/api/health', { - headers: { Accept: 'application/json' }, + const res = await fetch("/api/health", { + headers: { Accept: "application/json" }, }); return handleResponse(res); } \ No newline at end of file diff --git a/packages/ui/src/lib/landing-data.tsx b/packages/ui/src/lib/landing-data.tsx new file mode 100644 index 0000000..1000a47 --- /dev/null +++ b/packages/ui/src/lib/landing-data.tsx @@ -0,0 +1,80 @@ +export const FEATURES = [ + { + label: "Payments", + desc: "XLM and custom asset transfers between accounts", + }, + { + label: "Accounts", + desc: "Balances, trust lines, signers, flags, and home domain", + }, + { + label: "Set Options", + desc: "Account configuration changes decoded line by line", + }, + { + label: "Create Account", + desc: "New account funding and activation events", + }, + { label: "Change Trust", desc: "Asset trust line additions and removals" }, + { label: "Clawback", desc: "Regulated asset recovery operations explained" }, +]; + +export const STEPS = [ + { + n: "01", + title: "Paste a hash or address", + body: "Copy any transaction hash or account address from your wallet, exchange, or block explorer.", + icon: ( + + + + + ), + }, + { + n: "02", + title: "Hit Explain", + body: "Stellar Explain fetches the data from the Stellar network and processes each operation in real time.", + icon: ( + + + + + ), + }, + { + n: "03", + title: "Read plain English", + body: "Every operation is translated into a clear, human-readable explanation — no blockchain knowledge needed.", + icon: ( + + + + + + + + ), + }, +]; \ 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..1d704e5 --- /dev/null +++ b/packages/ui/src/lib/utils.ts @@ -0,0 +1,44 @@ +/** + * Utility functions for Stellar Explain UI. + * Pure functions — no side effects, no API calls. + */ + +/** + * Shortens a Stellar address for display. + * GABC...WXYZ + */ +export function shortAddr(addr: string): string { + if (!addr || addr.length < 12) return addr; + return `${addr.slice(0, 6)}…${addr.slice(-4)}`; +} + +/** + * Formats an XLM balance string for display. + * Strips unnecessary trailing zeros but preserves meaningful decimals. + * "35.6096767" → "35.61" + * "0.0000002" → "0.0000002" + * "100.0000000" → "100" + */ +export function formatBalance(raw: string): string { + const num = parseFloat(raw); + if (isNaN(num)) return raw; + + // If it's a very small number, show enough decimal places to be meaningful + if (num > 0 && num < 0.001) { + return num.toPrecision(2); + } + + // Otherwise format with up to 2 decimal places, stripping trailing zeros + return num.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); +} + +/** + * Formats a ledger timestamp for display. + * "2026-02-18T08:02:05Z" → "2026-02-18 08:02:05 UTC" + */ +export function formatLedgerTime(iso: string): string { + return iso.replace("T", " ").replace("Z", " UTC"); +} \ No newline at end of file diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts new file mode 100644 index 0000000..a9c919b --- /dev/null +++ b/packages/ui/src/types/index.ts @@ -0,0 +1,48 @@ +export interface PaymentExplanation { + summary: string; + from: string; + to: string; + asset: string; + amount: string; +} + +export interface TransactionExplanation { + transaction_hash: string; + successful: boolean; + summary: string; + payment_explanations: PaymentExplanation[]; + skipped_operations: number; + memo_explanation: string | null; + fee_explanation: string | null; + ledger_closed_at: string | null; + ledger: number | null; +} + +export interface AccountExplanation { + address: string; + summary: string; + xlm_balance: string; + asset_count: number; + signer_count: number; + home_domain: string | null; + org_name: string | null; + flag_descriptions: string[]; +} + +export interface HealthResponse { + status: string; + network: string; + horizon_reachable: boolean; + version: string; +} + +export interface ApiError { + error: { + code: string; + message: string; + }; +} + +export type Tab = "tx" | "account"; + +export type PillVariant = "success" | "fail" | "default" | "warning"; \ No newline at end of file