Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

# misc
.DS_Store
**/__pycache__/
*.pem

# debug
Expand Down
202 changes: 81 additions & 121 deletions README.md

Large diffs are not rendered by default.

152 changes: 152 additions & 0 deletions app/(marketing)/content/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import type { Metadata } from "next";
import { getAllPostMetas } from "@/lib/blog";
import PostCard from "@/components/blog/PostCard";
import { Play } from "lucide-react";

const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "";

export const metadata: Metadata = {
title: "Content",
description:
"Videos, guides, and technical deep dives on LumenWipe - closing Stellar accounts, recovering XLM reserves, and DeFi position unwinding.",
openGraph: {
title: "Content | LumenWipe",
description: "Videos and articles on Stellar account management and reserve recovery.",
url: `${APP_URL}/content`,
type: "website",
},
alternates: {
canonical: `${APP_URL}/content`,
},
};

type Video = {
id: string;
title: string;
description: string;
lang: "EN" | "ES";
duration: string;
};

const VIDEOS: Video[] = [
{
id: "vD3xhPpqah8",
title: "What is LumenWipe?",
description:
"A 3-minute overview of what LumenWipe does, why Stellar reserves get locked, and how the non-custodial close flow works.",
lang: "EN",
duration: "3 min",
},
{
id: "nVS2zI9mRzw",
title: "Full walkthrough: testnet, playground & mainnet",
description:
"An 11-minute demo covering the full demolition flow - from testnet dry run to a live mainnet account close.",
lang: "EN",
duration: "11 min",
},
{
id: "eRkcNd9996c",
title: "Demo completo: testnet, playground y mainnet",
description:
"Demo de 14 minutos que cubre el flujo completo: testnet, playground y cierre real en mainnet.",
lang: "ES",
duration: "14 min",
},
];

function Eyebrow({ children }: { children: React.ReactNode }) {
return (
<span className="mkt-eyebrow inline-flex items-center gap-2 text-stellar/90">
<span className="h-px w-6 bg-stellar/50" />
{children}
</span>
);
}

function LangBadge({ lang }: { lang: "EN" | "ES" }) {
return (
<span className="mkt-mono rounded-md border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[0.62rem] uppercase tracking-wider text-white/45">
{lang}
</span>
);
}

function DurationBadge({ duration }: { duration: string }) {
return (
<span className="mkt-mono inline-flex items-center gap-1 text-[0.68rem] text-white/35">
<Play className="h-2.5 w-2.5" />
{duration}
</span>
);
}

export default function ContentPage() {
const posts = getAllPostMetas();

return (
<div className="mx-auto max-w-5xl px-5 py-14 lg:px-8 lg:py-20">
{/* Header */}
<div className="mb-14">
<Eyebrow>Content</Eyebrow>
<h1 className="mkt-display mt-4 mb-3 text-4xl font-extrabold tracking-tight text-white">
Videos &amp; articles
</h1>
<p className="max-w-2xl text-base leading-relaxed text-white/70">
Walkthroughs, deep dives, and guides on closing Stellar accounts and recovering locked
reserves.
</p>
</div>

{/* Videos */}
<section className="mb-16">
<h2 className="mkt-display mb-6 text-xl font-bold text-white">Videos</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{VIDEOS.map((v) => (
<div key={v.id} className="mkt-card flex flex-col overflow-hidden">
{/* 16:9 embed */}
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
<iframe
src={`https://www.youtube-nocookie.com/embed/${v.id}`}
title={v.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
loading="lazy"
className="absolute inset-0 h-full w-full border-0"
/>
</div>
{/* Metadata */}
<div className="flex flex-1 flex-col gap-2 p-4">
<div className="flex items-center gap-2">
<LangBadge lang={v.lang} />
<DurationBadge duration={v.duration} />
</div>
<h3 className="mkt-display text-[0.95rem] font-semibold leading-snug text-white">
{v.title}
</h3>
<p className="text-[0.82rem] leading-relaxed text-white/55">{v.description}</p>
</div>
</div>
))}
</div>
</section>

{/* Divider */}
<div className="mb-14 border-t border-white/[0.07]" />

{/* Blog */}
<section>
<h2 className="mkt-display mb-6 text-xl font-bold text-white">From the blog</h2>
{posts.length > 0 ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{posts.map((post) => (
<PostCard key={post.slug} post={post} />
))}
</div>
) : (
<p className="text-sm text-white/45">No articles published yet.</p>
)}
</section>
</div>
);
}
2 changes: 1 addition & 1 deletion app/(marketing)/faq/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ArrowRight } from "lucide-react";
import Faq from "@/components/marketing/Faq";

export const metadata: Metadata = {
title: "FAQ LumenWipe",
title: "FAQ - LumenWipe",
description:
"Answers to the common questions about closing a Stellar account with LumenWipe: non-custodial signing, irreversibility, exchange merges, Soroban DeFi, supported wallets and resumable sessions.",
};
Expand Down
18 changes: 14 additions & 4 deletions app/(marketing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ArrowRight, Check, RefreshCw, Building2, Layers, Eye, ScanLine } from "
import Faq from "@/components/marketing/Faq";
import Reveal from "@/components/marketing/Reveal";
import HeroConsole from "@/components/marketing/HeroConsole";
import HeroAccountInput from "@/components/marketing/HeroAccountInput";

export const metadata: Metadata = {
title: "LumenWipe: Recover the XLM locked in your Stellar account",
Expand Down Expand Up @@ -102,9 +103,13 @@ export default function LandingPage() {
<>
{/* ============================ HERO ============================ */}
<section className="mx-auto max-w-6xl px-5 pb-10 pt-16 text-center lg:px-8 lg:pt-20">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 mkt-mono text-[0.68rem] uppercase tracking-wider text-white/65">
<span className="h-1.5 w-1.5 rounded-full bg-stellar mkt-pulse" />
Stellar Account Demolisher
{/* Label - typographic, not a status pill */}
<div className="mb-8 flex items-center justify-center gap-4">
<span className="h-px w-12 bg-white/15" />
<span className="mkt-mono text-[0.65rem] uppercase tracking-[0.2em] text-white/35">
Stellar Account Demolisher
</span>
<span className="h-px w-12 bg-white/15" />
</div>

<h1 className="mkt-display mx-auto max-w-[15ch] text-[2.7rem] font-extrabold leading-[0.95] text-white sm:text-6xl lg:text-[4.2rem]">
Expand All @@ -116,7 +121,12 @@ export default function LandingPage() {
leftovers, and merge out. Signed entirely in your browser.
</p>

<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
{/* Inline account analyzer */}
<div className="mt-8">
<HeroAccountInput />
</div>

<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
<Link href={APP} className={`group ${btnGold}`}>
Open the app
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
Expand Down
2 changes: 1 addition & 1 deletion app/(marketing)/security/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from "lucide-react";

export const metadata: Metadata = {
title: "Security LumenWipe",
title: "Security - LumenWipe",
description:
"LumenWipe builds transactions that drain accounts irreversibly, so the design starts from that fact. Keys are created and used only in your browser; the read-only backend can never move your funds.",
};
Expand Down
13 changes: 11 additions & 2 deletions app/api/v1/[network]/close/transactions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,12 @@ export async function POST(
);
}

const transactions = await buildCloseTransactions(accountState, destination, dispositions, network);
const transactions = await buildCloseTransactions(
accountState,
destination,
dispositions,
network
);

const planHash = computePlanHash({
source,
Expand All @@ -93,7 +98,11 @@ export async function POST(
} catch (e) {
if (e instanceof AccountNotFoundError) return err("account_not_found", e.message, 404);
if (e instanceof AssetRouteLostError) {
return err("quote_drifted", "A conversion route is no longer available; re-plan and retry.", 409);
return err(
"quote_drifted",
"A conversion route is no longer available; re-plan and retry.",
409
);
}
if (e instanceof CloseBuildError) return err(e.code, e.message, e.status);
console.error("close/transactions error:", e);
Expand Down
2 changes: 1 addition & 1 deletion app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
backdrop-filter: blur(8px);
}

/* Redesign: crisp, flat card depth from edge-light + tight shadow, no glow halo. */
/* Redesign: crisp, flat card - depth from edge-light + tight shadow, no glow halo. */
.mkt-card {
background: hsl(var(--mkt-panel));
border: 1px solid hsl(var(--mkt-line) / 0.1);
Expand Down
2 changes: 1 addition & 1 deletion components/complete/CompletionReceipt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default function CompletionReceipt({ network }: CompletionReceiptProps) {

// The "what was done" groups below describe state changes; the transaction ledger
// describes the real on-chain transactions. A fused close is one transaction, a
// mediator merge is two, and DeFi exits will each add their own the ledger reflects
// mediator merge is two, and DeFi exits will each add their own - the ledger reflects
// that count instead of implying one transaction per group.
const ledger = buildTxLedger(confirmedSteps);

Expand Down
75 changes: 75 additions & 0 deletions components/marketing/HeroAccountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"use client";

import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Search, AlertCircle } from "lucide-react";

function isValidStellarAddress(addr: string): boolean {
return /^G[A-Z2-7]{55}$/.test(addr);
}

export default function HeroAccountInput() {
const router = useRouter();
const [address, setAddress] = useState("");
const [error, setError] = useState<string | null>(null);

function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmed = address.trim();
if (!trimmed) {
setError("Enter a Stellar account address.");
return;
}
if (!isValidStellarAddress(trimmed)) {
setError("Not a valid Stellar address - must start with G and be 56 characters.");
return;
}
setError(null);
router.push(`/mainnet/analyze?source=${encodeURIComponent(trimmed)}`);
}

return (
<div className="w-full max-w-2xl mx-auto">
<form onSubmit={handleSubmit}>
<div
className={`flex items-center gap-2 rounded-2xl border bg-white/[0.04] p-2 transition-colors focus-within:border-stellar/50 ${
error ? "border-red-500/40" : "border-white/12"
}`}
>
<Search className="ml-2 h-4 w-4 shrink-0 text-white/30" />
<input
type="text"
value={address}
onChange={(e) => {
setAddress(e.target.value);
if (error) setError(null);
}}
placeholder="Paste a Stellar account address - G…"
className="flex-1 bg-transparent py-2 pr-2 mkt-mono text-[0.8rem] text-white placeholder:text-white/25 outline-none min-w-0"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
/>
<button
type="submit"
className="shrink-0 rounded-xl bg-value px-5 py-2.5 text-sm font-semibold text-[hsl(var(--value-foreground))] transition-all hover:bg-value/90 hover:shadow-[0_8px_24px_-8px_hsl(var(--value)/0.6)]"
>
Analyze account
</button>
</div>
</form>

{error ? (
<p className="mt-2.5 flex items-center gap-1.5 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error}
</p>
) : (
<p className="mt-2.5 text-center text-xs text-white/35">
Analyzes on mainnet · read-only until you sign · no account needed
</p>
)}
</div>
);
}
4 changes: 2 additions & 2 deletions components/marketing/HeroConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const TOTAL = ROWS.reduce((s, r) => s + r.amount, 0);
// Row height in px. MUST match the `h-11` on each row so the sliding highlight lines up.
const ROW_HEIGHT_PX = 44;

// Scan cadence in ms tuned for a calm read (~8.5s per loop).
// Scan cadence in ms - tuned for a calm read (~8.5s per loop).
const TIMING = {
startDelay: 800, // before the first step lights up
perStep: 850, // how long each step stays active
Expand Down Expand Up @@ -108,7 +108,7 @@ export default function HeroConsole() {
done ? "text-white/60" : "text-white/30"
}`}
>
{r.amount > 0 ? `+${r.amount.toFixed(2)}` : ""}
{r.amount > 0 ? `+${r.amount.toFixed(2)}` : "-"}
</span>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion components/marketing/MarketingFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const COLS: { title: string; links: { label: string; href: string; external?: bo
{ label: "Documentation", href: "https://docs.lumenwipe.com", external: true },
{ label: "Architecture", href: "https://docs.lumenwipe.com/architecture", external: true },
{ label: "Security", href: "/security" },
{ label: "Blog", href: "/blog" },
{ label: "Content", href: "/content" },
],
},
{
Expand Down
5 changes: 3 additions & 2 deletions components/marketing/MarketingNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const LINKS: NavLink[] = [
{ href: "/faq", label: "FAQ" },
{ href: "/playground", label: "Playground" },
{ href: "/stats", label: "Stats" },
{ href: "/blog", label: "Blog" },
{ href: "/content", label: "Content" },
];

const DOCS: NavLink = { href: "https://docs.lumenwipe.com", label: "Docs", external: true };
Expand Down Expand Up @@ -81,7 +81,8 @@ export default function MarketingNav() {
function isActive(l: NavLink): boolean {
if (l.external) return false;
if (l.section) return pathname === "/" && activeSection === l.section;
if (l.href === "/blog") return pathname === "/blog" || pathname.startsWith("/blog/");
if (l.href === "/content")
return pathname === "/content" || pathname === "/blog" || pathname.startsWith("/blog/");
return pathname === l.href;
}

Expand Down
Loading
Loading