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
5 changes: 5 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
# Public site URL (used in metadata, image URLs, share cards)
NEXT_PUBLIC_SITE_URL=http://localhost:3000

# Search-engine ownership verification (optional; renders <meta name="google-site-verification"> /
# <meta name="msvalidate.01"> when set). Production: `fly secrets set GOOGLE_SITE_VERIFICATION=… BING_SITE_VERIFICATION=…`
GOOGLE_SITE_VERIFICATION=
BING_SITE_VERIFICATION=

# Browser RPCs (the site proxies mainnet reads through /api/rpc; these are for local anvil forks)
NEXT_PUBLIC_RPC_URL_BASE=http://127.0.0.1:8545
NEXT_PUBLIC_RPC_URL_ROBINHOOD=http://127.0.0.1:8546
Expand Down
3 changes: 3 additions & 0 deletions app/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import LaunchHero from "@/components/launchpad/LaunchHero";
import LaunchList from "@/components/launchpad/LaunchList";
Expand All @@ -13,6 +14,8 @@ import { isFilter } from "@/lib/launchpad/search";
import TrendingStrip from "@/components/launchpad/TrendingStrip";

export const dynamic = "force-dynamic";
// Title and description come from the root layout; ?sort= / ?chain= / ?window= views are all this one page.
export const metadata: Metadata = { alternates: { canonical: "/" } };

export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: string; window?: string; chain?: string; filter?: string }> }) {
const sp = await searchParams;
Expand Down
103 changes: 103 additions & 0 deletions app/src/app/about/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/seo";
import Link from "next/link";
import { ArrowRight, ArrowUpRight } from "lucide-react";
import SectionIntro from "@/components/sections/SectionIntro";
import shell from "@/components/sections/SectionShell.module.css";
import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, LEGACY_DOMAIN } from "@/lib/brand";
import { CHAIN_KEYS, CHAIN_LABELS, CHAINS } from "@/lib/chainPublic";
import { chainLandingPath } from "@/lib/chainLanding";
import { hasChainPage } from "@/lib/launchpad/config";

/**
* The entity page: the one URL that answers "what is openlaunch" in plain words, names the
* official channels, and separates the brand from look-alikes. Facts only (CONTRIBUTING.md):
* what the contracts do, where the code is, which accounts are ours. No claims about value.
*/
export const metadata: Metadata = pageMetadata({
path: "/about",
title: `About ${BRAND}`,
description: `${BRAND} is a free, open-source token launchpad on Base, Robinhood Chain and Arc. What it does, who builds it, and which channels are official.`,
});

const DIFFERENCES = [
["No platform fee", "The factory and the locker have no fee address, fee variable or treasury. There is nothing to switch on later."],
["Liquidity locked forever", "100% of the supply goes into one Uniswap v4 position at launch. Its NFT is held by an ownerless locker with no withdraw path: not the creator, not openlaunch, nobody."],
["No pre-mine, no admin", "Every token starts inside the pool. The contracts have no owner, no pause, no upgrade and no allowlist. They are the same for everyone."],
["Open source", "Contracts and site are MIT-licensed on GitHub, and every deployed contract is source-verified on the chain's explorers."],
["Creator-set trading fee", "0%, 1% or 3%, chosen at launch, paid in full to the beneficiaries the creator names or burned. Fixed forever."],
["Open to agents", "A launch is one contract call from any wallet. The list, trades and metadata are also plain JSON, with no API key."],
] as const;

export default function AboutPage() {
return (
<main className={shell.page}>
<SectionIntro
eyebrow="About"
title={`What ${BRAND} is`}
description={<>{BRAND} is a free, open-source token launchpad. One transaction deploys a fixed-supply ERC-20, opens a Uniswap v4 pool at the market cap you pick and locks 100% of the supply as liquidity, forever. It runs on Base, Robinhood Chain and Arc.</>}
>
<Link href="/launch" className={shell.action}>Launch a token <ArrowRight size={15} aria-hidden="true" /></Link>
<Link href="/rules" className={shell.textLink}>How it works <ArrowRight size={14} aria-hidden="true" /></Link>
</SectionIntro>

<section className={shell.anchorSection} id="different" aria-labelledby="different-heading">
<h2 id="different-heading" className="font-display text-2xl sm:text-3xl font-bold tracking-tight text-ink">What makes {BRAND} different</h2>
<ul className="mt-6 grid gap-4 sm:grid-cols-2">
{DIFFERENCES.map(([title, body]) => (
<li key={title} className="rounded-2xl border border-line bg-paper p-5">
<h3 className="text-sm font-semibold text-ink">{title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted text-pretty">{body}</p>
</li>
))}
</ul>
</section>

<section className={`${shell.anchorSection} mt-14`} id="chains" aria-labelledby="chains-heading">
<h2 id="chains-heading" className="font-display text-2xl sm:text-3xl font-bold tracking-tight text-ink">Where it runs</h2>
<p className="mt-4 max-w-[40rem] text-base leading-relaxed text-body text-pretty">The same contract design is deployed on every chain, each paired with that chain&apos;s canonical Uniswap v4 contracts. Addresses and verification records are listed on the <Link href="/rules#contracts" className="text-brand underline decoration-line-strong underline-offset-4 hover:text-ink">contracts section</Link> of the how-it-works page.</p>
<dl className="mt-6 grid gap-3 sm:grid-cols-3">
{CHAIN_KEYS.map((chain) => (
<div key={chain} className="rounded-2xl border border-line bg-paper p-5">
<dt className="text-sm font-semibold text-ink">{hasChainPage(chain) ? <Link href={chainLandingPath(chain)} className="hover:text-brand underline decoration-line-strong underline-offset-4">{CHAIN_LABELS[chain]}</Link> : CHAIN_LABELS[chain]}</dt>
<dd className="mt-1 font-mono text-xs text-muted tnum">Chain ID {CHAINS[chain].id}</dd>
</div>
))}
</dl>
</section>

<section className={`${shell.anchorSection} mt-14`} id="who" aria-labelledby="who-heading">
<h2 id="who-heading" className="font-display text-2xl sm:text-3xl font-bold tracking-tight text-ink">Who builds it</h2>
<p className="mt-4 max-w-[40rem] text-base leading-relaxed text-body text-pretty">{BRAND} is built by Gitlawb and developed in the open. The contracts, the indexer and this site live in one MIT-licensed repository; contributions go through pull requests there.</p>
<p className="mt-3 max-w-[40rem] text-base leading-relaxed text-body text-pretty">Tokens launched here are created by their launchers, not by {BRAND}. A locked pool does not make a token valuable; read <Link href="/rules#know" className="text-brand underline decoration-line-strong underline-offset-4 hover:text-ink">before you begin</Link>.</p>
</section>

<section className={`${shell.anchorSection} mt-14`} id="official" aria-labelledby="official-heading">
<h2 id="official-heading" className="font-display text-2xl sm:text-3xl font-bold tracking-tight text-ink">Official channels</h2>
<p className="mt-4 max-w-[40rem] text-base leading-relaxed text-body text-pretty">These are the only places {BRAND} speaks from. Accounts, domains and repositories with similar names are not affiliated with {BRAND}, and {BRAND} is not related to other products that share the name.</p>
<ul className="mt-6 grid gap-3 sm:grid-cols-3">
<li className="rounded-2xl border border-line bg-paper p-5">
<p className="text-xs uppercase tracking-wide text-muted">Website</p>
<Link href="/" className="mt-1 inline-flex items-center gap-1 font-mono text-sm text-ink hover:text-brand">{BRAND_DOMAIN}</Link>
<p className="mt-2 text-xs leading-relaxed text-muted">Formerly {LEGACY_DOMAIN}; the old domain redirects here.</p>
</li>
<li className="rounded-2xl border border-line bg-paper p-5">
<p className="text-xs uppercase tracking-wide text-muted">X</p>
<a href={`https://x.com/${BRAND_X}`} target="_blank" rel="noreferrer" className="mt-1 inline-flex items-center gap-1 font-mono text-sm text-ink hover:text-brand">@{BRAND_X} <ArrowUpRight size={13} aria-hidden="true" /></a>
<p className="mt-2 text-xs leading-relaxed text-muted">Launch notes and updates.</p>
</li>
<li className="rounded-2xl border border-line bg-paper p-5">
<p className="text-xs uppercase tracking-wide text-muted">Source code</p>
<a href={BRAND_GITHUB} target="_blank" rel="noreferrer" className="mt-1 inline-flex items-center gap-1 font-mono text-sm text-ink hover:text-brand">Gitlawb/openlaunch <ArrowUpRight size={13} aria-hidden="true" /></a>
<p className="mt-2 text-xs leading-relaxed text-muted">MIT. Forks on GitHub are copies; this is the original.</p>
</li>
</ul>
</section>

<div className="mt-12 flex flex-col gap-3 sm:flex-row sm:items-center">
<Link href="/launch" className={shell.action}>Launch a token <ArrowRight size={15} aria-hidden="true" /></Link>
<Link href="/agents" className={shell.textLink}>Launch from an agent <ArrowRight size={14} aria-hidden="true" /></Link>
</div>
</main>
);
}
3 changes: 2 additions & 1 deletion app/src/app/agents/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/seo";
import { ArrowDown, ArrowRight, ArrowUpRight, Braces, FileCode2, Terminal } from "lucide-react";
import SectionIntro from "@/components/sections/SectionIntro";
import AgentsCodeBlock from "@/components/sections/AgentsCodeBlock";
Expand All @@ -8,7 +9,7 @@ import { launchpad } from "@/lib/launchpad/config";
import { CHAIN_KEYS, CHAIN_LABELS, CHAINS, SITE_URL, explorerAddress } from "@/lib/chainPublic";
import { BRAND_GITHUB } from "@/lib/brand";

export const metadata: Metadata = { title: "Agents", description: "Launch and trade tokens from an agent: one contract call, plus a JSON API for the list, trades and metadata." };
export const metadata: Metadata = pageMetadata({ path: "/agents", title: "Agents", description: "Launch and trade tokens from an agent: one contract call, plus a JSON API for the list, trades and metadata." });

const sections = [
{ id: "launch", number: "01", label: "Launch a token" },
Expand Down
8 changes: 8 additions & 0 deletions app/src/app/arc/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ChainLanding, { chainLandingMetadata } from "@/components/launchpad/ChainLanding";

export const dynamic = "force-dynamic";
export const metadata = chainLandingMetadata("arc");

export default function Page() {
return <ChainLanding chain="arc" />;
}
8 changes: 8 additions & 0 deletions app/src/app/base/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ChainLanding, { chainLandingMetadata } from "@/components/launchpad/ChainLanding";

export const dynamic = "force-dynamic";
export const metadata = chainLandingMetadata("base");

export default function Page() {
return <ChainLanding chain="base" />;
}
3 changes: 2 additions & 1 deletion app/src/app/feed/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/seo";
import Link from "next/link";
import { ArrowUpRight } from "lucide-react";
import CommunityFeed from "@/components/sections/CommunityFeed";
import SectionIntro from "@/components/sections/SectionIntro";
import styles from "@/components/sections/SectionShell.module.css";
import { listFeed } from "@/lib/launchpad/postsServer";

export const metadata: Metadata = { title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol." };
export const metadata: Metadata = pageMetadata({ path: "/feed", title: "Posts", description: "What people are saying about tokens launched on openlaunch.lol." });
export const dynamic = "force-dynamic";

export default async function FeedPage() {
Expand Down
6 changes: 4 additions & 2 deletions app/src/app/launch/page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/seo";
import LaunchForm from "@/components/launchpad/LaunchForm";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer";
import { DEFAULT_CHAIN, chainKeyOr, type ChainKey } from "@/lib/chainPublic";
import { CONFIGURED_CHAINS, launchpad } from "@/lib/launchpad/config";

export const metadata: Metadata = {
export const metadata: Metadata = pageMetadata({
path: "/launch",
title: "Launch a token for free",
description: "Deploy a token on Base, Robinhood Chain or Arc with 100% of supply locked as Uniswap v4 liquidity. No platform fee. Gas only.",
};
});

export const dynamic = "force-dynamic";

Expand Down
15 changes: 11 additions & 4 deletions app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import Web3Provider from "@/components/Web3Provider";
import Header from "@/components/Header";
import TxToasts from "@/components/launchpad/TxToasts";
import LiveProvider from "@/components/launchpad/LiveProvider";
import { getLaunchFeed, getLaunchTotals } from "@/lib/launchpad/queries";
import { getLaunchFeed, getLaunchTotalsForRequest } from "@/lib/launchpad/queries";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { SITE_URL } from "@/lib/chainPublic";
import { BRAND, BRAND_DOMAIN, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "@/lib/brand";
import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "@/lib/brand";
import { siteJsonLdHtml, siteVerification } from "@/lib/seo";
import Footer from "@/components/Footer";
import RouteProgress from "@/components/RouteProgress";
import ThemeProvider from "@/components/ThemeProvider";
Expand All @@ -23,7 +24,10 @@ export const metadata: Metadata = {
title: { default: TITLE, template: `%s · ${BRAND_DOMAIN}` },
description: DESCRIPTION,
applicationName: BRAND,
alternates: { canonical: "/" },
// No canonical here: metadata merges shallowly, so a root canonical of "/" was inherited by every
// page without its own (launch, rules, feed, agents) and told search engines they were duplicates
// of the home page. Each indexable page declares its own canonical.
verification: siteVerification(process.env),
openGraph: { siteName: BRAND_DOMAIN, title: TITLE, description: SOCIAL_DESCRIPTION, type: "website", url: SITE_URL },
twitter: { card: "summary_large_image", site: `@${BRAND_X}`, title: TITLE, description: SOCIAL_DESCRIPTION },
};
Expand All @@ -36,10 +40,13 @@ export default async function RootLayout({ children }: { children: React.ReactNo
// Minted per request in src/proxy.ts; next-themes' inline theme script must carry it to run under the CSP.
const nonce = (await headers()).get("x-nonce") ?? undefined;
const usd = await ethUsd();
const [feed, totals] = await Promise.all([getLaunchFeed(24, usd).catch(() => []), getLaunchTotals(usd)]);
const [feed, totals] = await Promise.all([getLaunchFeed(24, usd).catch(() => []), getLaunchTotalsForRequest(usd)]);
// Organization + WebSite + WebApplication: the entity record search engines reconcile the brand word against.
const siteJsonLd = siteJsonLdHtml({ siteUrl: SITE_URL, brand: BRAND, domain: BRAND_DOMAIN, description: SITE_DESCRIPTION, sameAs: [`https://x.com/${BRAND_X}`, BRAND_GITHUB] });
return (
<html lang="en" className={`${inter.variable} ${spaceMono.variable} ${unbounded.variable}`} suppressHydrationWarning>
<body id="site-top" tabIndex={-1} className="min-h-screen flex flex-col">
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: siteJsonLd }} />
<ThemeProvider nonce={nonce}>
<Web3Provider>
<LiveProvider initial={{ at: 0, feed, totals, ethUsd: usd }}>
Expand Down
4 changes: 2 additions & 2 deletions app/src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export function GET() {
const a = launchpad("arc");
const body = `# ${BRAND_DOMAIN}

> Open-source (MIT: https://github.com/Gitlawb/openlaunch), zero-fee token launchpad on Base (8453), Robinhood Chain (4663) and Arc (5042). One transaction deploys a token and locks 100% of its supply as
> Uniswap v4 liquidity, forever. No platform fee: the factory and locker have no fee address at all.
> openlaunch (${BRAND_DOMAIN}) is an open-source (MIT: https://github.com/Gitlawb/openlaunch), zero-fee token launchpad on Base (8453), Robinhood Chain (4663) and Arc (5042). One transaction deploys a token and locks 100% of its supply as
> Uniswap v4 liquidity, forever. No platform fee: the factory and locker have no fee address at all. About page: ${SITE_URL}/about

## Source verification
${verificationLine("Base (8453)", b, [
Expand Down
3 changes: 2 additions & 1 deletion app/src/app/me/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import MeDashboard from "@/components/launchpad/MeDashboard";
import SectionIntro from "@/components/sections/SectionIntro";
import shell from "@/components/sections/SectionShell.module.css";

export const metadata: Metadata = { title: "Me", description: "Your launches, fees, holdings and trades on Base, Robinhood Chain and Arc." };
// Wallet-specific: the page is empty for a crawler, so it stays out of the index (and out of the sitemap).
export const metadata: Metadata = { title: "Me", description: "Your launches, fees, holdings and trades on Base, Robinhood Chain and Arc.", robots: { index: false, follow: true } };
export const dynamic = "force-dynamic";

export default function MePage() {
Expand Down
41 changes: 41 additions & 0 deletions app/src/app/metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";

const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8");
const layout = read("./layout.tsx");

/**
* Next merges metadata shallowly, so a canonical on the root layout is inherited by every page
* that does not set `alternates` itself. That once canonicalised /launch, /rules, /feed and /agents
* to "/" and told search engines they were duplicates of the home page. Each indexable page owns
* its canonical; the root layout must never carry one.
*/
test("root layout declares no canonical and renders the site entity JSON-LD", () => {
assert.doesNotMatch(layout, /alternates/);
assert.match(layout, /siteJsonLdHtml\(/);
assert.match(layout, /verification: siteVerification\(process\.env\)/);
assert.match(layout, /<script type="application\/ld\+json" dangerouslySetInnerHTML=\{\{ __html: siteJsonLd \}\} \/>/);
});

test("every indexable page declares its own canonical path and share card", () => {
const pages: [string, string][] = [
["./(home)/page.tsx", "/"],
["./launch/page.tsx", "/launch"],
["./rules/page.tsx", "/rules"],
["./feed/page.tsx", "/feed"],
["./agents/page.tsx", "/agents"],
["./about/page.tsx", "/about"],
];
for (const [file, path] of pages) {
const src = read(file);
// the home page's title, description and card are the layout's own; every other page goes through pageMetadata
const ok = path === "/" ? src.includes(`alternates: { canonical: "/" }`) : new RegExp(`pageMetadata\\(\\{\\s*path: "${path}"`).test(src);
assert.ok(ok, `${file} must canonicalise to ${path}`);
}
});

test("wallet-specific and moderation pages stay out of the index", () => {
assert.match(read("./me/page.tsx"), /robots: \{ index: false/);
assert.match(read("./admin/page.tsx"), /robots: \{ index: false/);
});
8 changes: 8 additions & 0 deletions app/src/app/robinhood/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ChainLanding, { chainLandingMetadata } from "@/components/launchpad/ChainLanding";

export const dynamic = "force-dynamic";
export const metadata = chainLandingMetadata("robinhood");

export default function Page() {
return <ChainLanding chain="robinhood" />;
}
Loading
Loading