From 77c7be1212400a6965595b17e0b5960318ee0690 Mon Sep 17 00:00:00 2001 From: Tinna23 Date: Tue, 24 Mar 2026 20:00:03 +0100 Subject: [PATCH] feat(ui): main app page, result pages, routing, and Next.js config [UI #5 & UI #8] --- .../ui/src/app/account/[address]/page.tsx | 183 ++++++++++++++++++ packages/ui/src/app/app/page.tsx | 83 ++++++++ packages/ui/src/app/page.tsx | 103 +--------- packages/ui/src/app/tx/[hash]/page.tsx | 160 +++++++++++++++ packages/ui/src/components/AccountResult.tsx | 101 ++++++++++ packages/ui/src/components/AppShell.tsx | 92 +++++++++ .../ui/src/components/AppShellContext.tsx | 34 ++++ packages/ui/src/components/SearchBar.tsx | 54 ++++++ .../ui/src/components/TransactionResult.tsx | 123 ++++++++++++ 9 files changed, 832 insertions(+), 101 deletions(-) create mode 100644 packages/ui/src/app/account/[address]/page.tsx create mode 100644 packages/ui/src/app/app/page.tsx create mode 100644 packages/ui/src/app/tx/[hash]/page.tsx create mode 100644 packages/ui/src/components/AccountResult.tsx create mode 100644 packages/ui/src/components/AppShell.tsx create mode 100644 packages/ui/src/components/AppShellContext.tsx create mode 100644 packages/ui/src/components/SearchBar.tsx create mode 100644 packages/ui/src/components/TransactionResult.tsx diff --git a/packages/ui/src/app/account/[address]/page.tsx b/packages/ui/src/app/account/[address]/page.tsx new file mode 100644 index 0000000..1f1a8c2 --- /dev/null +++ b/packages/ui/src/app/account/[address]/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { fetchAccount, getErrorMessage } from "@/lib/api"; +import type { AccountExplanation } from "@/types"; +import { AccountResult } from "@/components/AccountResult"; +import AppShell from "@/components/AppShell"; +import { useAppShell } from "@/components/AppShellContext"; + +// ── Inner page — consumes context ────────────────────────────────────────── + +function AccountPageInner() { + const { address } = useParams<{ address: string }>(); + const router = useRouter(); + const { addEntry, isSaved, getEntry, saveAddress, removeAddress } = + useAppShell(); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!address) return; + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const result = await fetchAccount(address); + if (!cancelled) { + setData(result); + addEntry("account", address, result.summary); + } + } catch (err) { + if (!cancelled) setError(getErrorMessage(err)); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, [address, addEntry]); + + return ( +
+ {/* Back button */} + + + {/* {loading && } */} + {loading && ( +

Loading...

+ )} + + {error && !loading && ( +
+ {error} +
+ )} + + {data && !loading && ( + { + const entry = getEntry(address); + if (entry) removeAddress(entry.id); + }} + /> + )} +
+ ); +} + +// ── Page — wraps with AppShell ───────────────────────────────────────────── + +export default function AccountPage() { + return ( + + + + ); +} + +// ── Skeleton ─────────────────────────────────────────────────────────────── + +// function AccountSkeleton() { +// return ( +//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+// ); +// } diff --git a/packages/ui/src/app/app/page.tsx b/packages/ui/src/app/app/page.tsx new file mode 100644 index 0000000..b8f46be --- /dev/null +++ b/packages/ui/src/app/app/page.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { TabSwitcher } from "@/components/TabSwitcher"; +import AppShell from "@/components/AppShell"; +import { SearchBar } from "@/components/SearchBar"; + +export default function AppPage() { + const router = useRouter(); + const [tab, setTab] = useState<"tx" | "account">("tx"); + const [txInput, setTxInput] = useState(""); + const [accountInput, setAccountInput] = useState(""); + const [error, setError] = useState(null); + + const input = tab === "tx" ? txInput : accountInput; + const setInput = tab === "tx" ? setTxInput : setAccountInput; + + function handleExplain() { + const trimmed = input.trim(); + if (!trimmed) return; + setError(null); + + if (tab === "tx") { + if (trimmed.length !== 64) { + setError("Transaction hash must be 64 characters."); + return; + } + router.push(`/tx/${trimmed}`); + } else { + if (!trimmed.startsWith("G") || trimmed.length !== 56) { + setError("Please enter a valid Stellar account address."); + return; + } + router.push(`/account/${trimmed}`); + } + } + + return ( + + {/* Hero block */} +
+
+

+ Plain-English explanations for Stellar blockchain operations. + Paste any transaction hash or account address below. +

+
+
+ + + + + + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Empty state */} + {!error && ( +
+

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

+
+ )} +
+ ); +} \ No newline at end of file diff --git a/packages/ui/src/app/page.tsx b/packages/ui/src/app/page.tsx index a932894..6e87ade 100644 --- a/packages/ui/src/app/page.tsx +++ b/packages/ui/src/app/page.tsx @@ -1,103 +1,4 @@ -import Image from "next/image"; - +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- - -
- -
- ); + redirect("/app"); } diff --git a/packages/ui/src/app/tx/[hash]/page.tsx b/packages/ui/src/app/tx/[hash]/page.tsx new file mode 100644 index 0000000..9c69e23 --- /dev/null +++ b/packages/ui/src/app/tx/[hash]/page.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { fetchTransaction, getErrorMessage } from "@/lib/api"; +import type { TransactionExplanation } from "@/types"; +import { TransactionResult } from "@/components/TransactionResult"; +import AppShell from "@/components/AppShell"; +import { useAppShell } from "@/components/AppShellContext"; + +// ── Inner page — consumes context ────────────────────────────────────────── + +function TxPageInner() { + const { hash } = useParams<{ hash: string }>(); + const router = useRouter(); + const { addEntry } = useAppShell(); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!hash) return; + let cancelled = false; + + async function load() { + setLoading(true); + setError(null); + try { + const result = await fetchTransaction(hash); + if (!cancelled) { + setData(result); + addEntry("transaction", hash, result.summary); + } + } catch (err) { + if (!cancelled) setError(getErrorMessage(err)); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, [hash, addEntry]); + + return ( +
+ {/* Back button */} + + + {/* {loading && } */} + {loading && ( +

Loading...

+ )} + + {error && !loading && ( +
+ {error} +
+ )} + + {data && !loading && } +
+ ); +} + +// ── Page — wraps with AppShell ───────────────────────────────────────────── + +export default function TxPage() { + return ( + + + + ); +} + +// ── Skeleton ─────────────────────────────────────────────────────────────── + +// function TransactionSkeleton() { +// return ( +//
+//
+//
+//
+//
+//
+//
+//
+//
+// ); +// } diff --git a/packages/ui/src/components/AccountResult.tsx b/packages/ui/src/components/AccountResult.tsx new file mode 100644 index 0000000..03f27ac --- /dev/null +++ b/packages/ui/src/components/AccountResult.tsx @@ -0,0 +1,101 @@ +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"; +// import SaveAddressButton from "@/components/addressbook/SaveAddressButton"; +// import QRShareButton from "@/components/QRShareButton"; +// import { useAppShell } from "@/components/AppShellContext"; + +interface AccountResultProps { + data: AccountExplanation; + isSaved: boolean; + savedLabel?: string; + onSave: (label: string, address: string) => boolean; + onRemoveSaved: () => void; +} + +export function AccountResult({ data, isSaved, savedLabel, onSave, onRemoveSaved }: AccountResultProps) { +// const { personalise } = useAppShell(); + const shareUrl = typeof window !== "undefined" + ? `${window.location.origin}/account/${data.address}` + : ""; + + 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/AppShell.tsx b/packages/ui/src/components/AppShell.tsx new file mode 100644 index 0000000..b7998b0 --- /dev/null +++ b/packages/ui/src/components/AppShell.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { AppShellContext, AppShellContextValue } from "./AppShellContext"; + + +interface Props { + children: React.ReactNode; +} + +// Minimal AppShell — provides context with stub values. +// Replace this file progressively as UI #21, #22, #24 are implemented. +export default function AppShell({ children }: Props) { + const contextValue: AppShellContextValue = { + addEntry: () => {}, + isSaved: () => false, + getEntry: () => undefined, + saveAddress: () => false, + removeAddress: () => {}, + copy: (text) => { + navigator.clipboard.writeText(text).catch(() => null); + }, + personalise: (text) => text, + isPersonalModeActive: false, + }; + + return ( + +
+ {/* Grid background */} +
+ + {/* Horizon glow */} +
+ + {/* App header */} + + + {/* Page content */} +
+ {children} +
+
+ + ); +} diff --git a/packages/ui/src/components/AppShellContext.tsx b/packages/ui/src/components/AppShellContext.tsx new file mode 100644 index 0000000..8c8d28e --- /dev/null +++ b/packages/ui/src/components/AppShellContext.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { createContext, useContext } from "react"; + +export interface AppShellContextValue { + // Populated progressively as more issues are completed + addEntry: (type: "transaction" | "account", identifier: string, summary?: string) => void; + isSaved: (address: string) => boolean; + getEntry: (address: string) => { id: string; label: string } | undefined; + saveAddress: (label: string, address: string) => boolean; + removeAddress: (id: string) => void; + copy: (text: string) => void; + personalise: (text: string) => string; + isPersonalModeActive: boolean; +} + +// Safe default stubs so pages compile without errors +// even before the real implementations are wired in +const defaultValue: AppShellContextValue = { + addEntry: () => {}, + isSaved: () => false, + getEntry: () => undefined, + saveAddress: () => false, + removeAddress: () => {}, + copy: (text) => { navigator.clipboard.writeText(text).catch(() => null); }, + personalise: (text) => text, + isPersonalModeActive: false, +}; + +export const AppShellContext = createContext(defaultValue); + +export function useAppShell(): AppShellContextValue { + return useContext(AppShellContext); +} \ 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/TransactionResult.tsx b/packages/ui/src/components/TransactionResult.tsx new file mode 100644 index 0000000..3325f22 --- /dev/null +++ b/packages/ui/src/components/TransactionResult.tsx @@ -0,0 +1,123 @@ +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"; +// import QRShareButton from "@/components/QRShareButton"; +// import { useAppShell } from "@/components/AppShellContext"; + +interface TransactionResultProps { + data: TransactionExplanation; +} + +export function TransactionResult({ data }: TransactionResultProps) { +// const { personalise } = useAppShell(); + const shareUrl = typeof window !== "undefined" + ? `${window.location.origin}/tx/${data.transaction_hash}` + : ""; + + 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) => ( + + {/*

{personalise(p.summary)}

*/} +
+
+ + {/* */} +
+
+ + {/* */} +
+
+ + + {p.amount}{" "} + {p.asset} + +
+
+
+ ))} +
+ )} +
+ ); +} \ No newline at end of file