diff --git a/packages/ui/.dockerignore b/packages/ui/.dockerignore new file mode 100644 index 0000000..a1ab914 --- /dev/null +++ b/packages/ui/.dockerignore @@ -0,0 +1,33 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Next.js build output +.next +out + +# Environment files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor and OS artifacts +.DS_Store +*.pem +.vscode +.idea + +# Testing +coverage + +# TypeScript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/packages/ui/.prettierignore b/packages/ui/.prettierignore new file mode 100644 index 0000000..1f52349 --- /dev/null +++ b/packages/ui/.prettierignore @@ -0,0 +1,5 @@ +.next +out +node_modules +public +*.min.js diff --git a/packages/ui/.prettierrc b/packages/ui/.prettierrc new file mode 100644 index 0000000..ae038de --- /dev/null +++ b/packages/ui/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} \ No newline at end of file diff --git a/packages/ui/eslint.config.mjs b/packages/ui/eslint.config.mjs index 719cea2..0210be6 100644 --- a/packages/ui/eslint.config.mjs +++ b/packages/ui/eslint.config.mjs @@ -1,6 +1,6 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { FlatCompat } from '@eslint/eslintrc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -10,16 +10,7 @@ const compat = new FlatCompat({ }); const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), - { - ignores: [ - "node_modules/**", - ".next/**", - "out/**", - "build/**", - "next-env.d.ts", - ], - }, + ...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'), ]; -export default eslintConfig; +export default eslintConfig; \ No newline at end of file diff --git a/packages/ui/src/app/page.tsx b/packages/ui/src/app/page.tsx index 6e87ade..cd7bcef 100644 --- a/packages/ui/src/app/page.tsx +++ b/packages/ui/src/app/page.tsx @@ -1,4 +1,47 @@ -import { redirect } from "next/navigation"; -export default function Home() { - redirect("/app"); +'use client";'; +import HeroSection from '@/components/landing/HeroSection'; +import Navbar from '@/components/landing/Navbar'; + +export default function LandingPage() { + return ( +
+ {/* ── Background layers ── */} +
+
+
+
+ + + +
+ ); } 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/HeroSection.tsx b/packages/ui/src/components/landing/HeroSection.tsx new file mode 100644 index 0000000..1e9d310 --- /dev/null +++ b/packages/ui/src/components/landing/HeroSection.tsx @@ -0,0 +1,76 @@ +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; 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/tsconfig.json b/packages/ui/tsconfig.json index c133409..bb25d2d 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -5,6 +5,7 @@ "allowJs": true, "skipLibCheck": true, "strict": true, + "noUncheckedIndexedAccess": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", @@ -24,4 +25,4 @@ }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] -} +} \ No newline at end of file