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.
+
+
+
+
+
+
+ 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 (
+
+
+
+
+ Stellar Explain
+
+
+
+
+
+ )
+}
+
+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