feat(web+infra): polished front page, app at /, tracker at /status
- apps/web: redesigned landing (Hero/Scanner/HowItWorks/Features/Footer), honest live-vs-coming-soon badges, same-origin /api/scan, ember theme. - ecosystem.config.cjs: runnable — pyre-api/worker via `node --import tsx`, pyre-web via `next start`, fork mode, env wired. pm2 web+api verified online (api /health 200, scan 200, web 200). - infra/nginx/feedthepyre.com.conf: app at / (proxy :3000), API at /api (proxy :4000, prefix preserved), dev tracker at /status (static). - scripts/deploy-web.sh: sudo cutover (install vhost, nginx -t, reload, certbot --nginx --keep-until-expiring). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,37 +25,305 @@ body {
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 56rem;
|
||||
max-width: 64rem;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.25rem 5rem;
|
||||
padding: 2.5rem 1.25rem 4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4.5rem;
|
||||
}
|
||||
|
||||
/* Shared section heading */
|
||||
.section-heading {
|
||||
font-size: clamp(1.4rem, 4vw, 1.9rem);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.01em;
|
||||
margin: 0 0 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Status badges (live / partial / coming soon) */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.badge--live {
|
||||
color: #7be3a3;
|
||||
background: rgba(60, 200, 120, 0.12);
|
||||
border-color: rgba(60, 200, 120, 0.35);
|
||||
}
|
||||
.badge--partial {
|
||||
color: var(--color-ember-bright);
|
||||
background: rgba(255, 138, 61, 0.12);
|
||||
border-color: rgba(255, 138, 61, 0.35);
|
||||
}
|
||||
.badge--soon {
|
||||
color: var(--color-smoke);
|
||||
background: rgba(184, 169, 156, 0.1);
|
||||
border-color: rgba(184, 169, 156, 0.3);
|
||||
}
|
||||
|
||||
/* Hero / header */
|
||||
.hero {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
padding: 3rem 0 1rem;
|
||||
overflow: visible;
|
||||
}
|
||||
.hero__glow {
|
||||
position: absolute;
|
||||
top: -6rem;
|
||||
left: 50%;
|
||||
width: min(40rem, 90vw);
|
||||
height: 28rem;
|
||||
transform: translateX(-50%);
|
||||
background: radial-gradient(
|
||||
50% 50% at 50% 50%,
|
||||
rgba(255, 87, 34, 0.28),
|
||||
transparent 70%
|
||||
);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.hero > *:not(.hero__glow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.hero__eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.32em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-smoke);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.hero__title {
|
||||
font-size: clamp(3rem, 10vw, 5rem);
|
||||
font-weight: 800;
|
||||
font-size: clamp(3.5rem, 13vw, 6.5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.15em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, var(--color-ember-bright), var(--color-ember));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
text-shadow: 0 0 40px rgba(255, 87, 34, 0.35);
|
||||
text-shadow: 0 0 60px rgba(255, 87, 34, 0.45);
|
||||
}
|
||||
.hero__tagline {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.1rem, 3.5vw, 1.45rem);
|
||||
font-weight: 700;
|
||||
color: var(--color-ember-bright);
|
||||
margin: 0.75rem 0 0.25rem;
|
||||
margin: 1rem 0 0.75rem;
|
||||
}
|
||||
.hero__value {
|
||||
max-width: 38rem;
|
||||
margin: 0 auto 0.5rem;
|
||||
font-size: clamp(1rem, 2.5vw, 1.15rem);
|
||||
color: #f5ede6;
|
||||
}
|
||||
.hero__trust {
|
||||
color: var(--color-smoke);
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
.hero__cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin: 1.75rem 0 0.85rem;
|
||||
}
|
||||
.hero__secondary {
|
||||
color: var(--color-ember-bright);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
padding: 0 1rem;
|
||||
height: 48px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid rgba(255, 138, 61, 0.35);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.hero__secondary:hover {
|
||||
background: rgba(255, 138, 61, 0.08);
|
||||
border-color: rgba(255, 138, 61, 0.6);
|
||||
}
|
||||
.hero__note {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Scanner section */
|
||||
.scanner__head {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.scanner__sub {
|
||||
text-align: center;
|
||||
color: var(--color-smoke);
|
||||
margin: -0.75rem auto 0;
|
||||
max-width: 34rem;
|
||||
}
|
||||
.scanner__panel {
|
||||
border: 1px solid rgba(255, 138, 61, 0.22);
|
||||
background: linear-gradient(180deg, rgba(255, 87, 34, 0.06), rgba(26, 20, 18, 0.6));
|
||||
border-radius: 1rem;
|
||||
padding: 1.75rem 1.5rem;
|
||||
}
|
||||
.scanner__loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.25rem;
|
||||
color: var(--color-smoke);
|
||||
}
|
||||
.scanner__spinner {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 138, 61, 0.3);
|
||||
border-top-color: var(--color-ember-bright);
|
||||
animation: pyre-spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes pyre-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* How it works */
|
||||
.how__grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.how__step {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--color-coal);
|
||||
border-radius: 0.85rem;
|
||||
padding: 1.25rem 1.25rem 1.5rem;
|
||||
}
|
||||
.how__num {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 900;
|
||||
color: rgba(255, 138, 61, 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.how__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin: 0.35rem 0 0.45rem;
|
||||
}
|
||||
.how__body {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Feature / status cards */
|
||||
.features__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.feature-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--color-coal);
|
||||
border-radius: 0.85rem;
|
||||
padding: 1.25rem 1.35rem 1.4rem;
|
||||
}
|
||||
.feature-card--live {
|
||||
border-color: rgba(60, 200, 120, 0.3);
|
||||
}
|
||||
.feature-card--partial {
|
||||
border-color: rgba(255, 138, 61, 0.3);
|
||||
}
|
||||
.feature-card__top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.feature-card__title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
}
|
||||
.feature-card__body {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.footer__brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.footer__wordmark {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--color-ember-bright);
|
||||
}
|
||||
.footer__motto {
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.footer__links a {
|
||||
color: #f5ede6;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.footer__links a:hover {
|
||||
color: var(--color-ember-bright);
|
||||
}
|
||||
.footer__disclaimer {
|
||||
max-width: 40rem;
|
||||
color: var(--color-smoke);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.6;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
/* Connect / scan controls */
|
||||
.connect {
|
||||
@@ -64,7 +332,6 @@ body {
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.scan-btn {
|
||||
|
||||
@@ -1,204 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useWallet } from "@solana/wallet-adapter-react";
|
||||
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
|
||||
import { TokenClassification } from "@pyre/core";
|
||||
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
||||
|
||||
// Display order + friendly headings. Wording rules: never say "safe"; protected /
|
||||
// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible".
|
||||
const SECTIONS: ReadonlyArray<{
|
||||
classification: TokenClassification;
|
||||
heading: string;
|
||||
blurb: string;
|
||||
}> = [
|
||||
{
|
||||
classification: TokenClassification.EMPTY_CLOSE_ONLY,
|
||||
heading: "Closeable empty accounts",
|
||||
blurb: "Empty token accounts that appear eligible to close and reclaim rent.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.INCINERATE_ONLY,
|
||||
heading: "Burnable junk",
|
||||
blurb: "Worthless leftover balances that appear eligible to burn.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.TRANSMUTABLE,
|
||||
heading: "Transmutable scraps",
|
||||
blurb: "Scraps that appear eligible to feed the fire.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.PROTECTED_SKIP,
|
||||
heading: "Protected / skipped",
|
||||
blurb: "Skipped — not eligible for cleanup. Left untouched.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.UNSUPPORTED,
|
||||
heading: "Unsupported",
|
||||
blurb: "Not eligible — PYRE can't reason about these, so they're skipped.",
|
||||
},
|
||||
];
|
||||
|
||||
function truncate(addr: string): string {
|
||||
if (addr.length <= 10) return addr;
|
||||
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
function lamportsToSol(lamports: string): number {
|
||||
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
|
||||
try {
|
||||
return Number(BigInt(lamports)) / 1e9;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function AccountRow({ account }: { account: TokenAccountDto }) {
|
||||
const label = account.symbol ?? account.name ?? truncate(account.mint);
|
||||
return (
|
||||
<li className="account-row">
|
||||
<div className="account-row__main">
|
||||
<span className="account-row__label" title={account.mint}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="account-row__mint" title={account.mint}>
|
||||
{truncate(account.mint)}
|
||||
</span>
|
||||
<span className="account-row__balance">{account.uiBalance}</span>
|
||||
</div>
|
||||
{account.warnings.length > 0 && (
|
||||
<ul className="account-row__warnings">
|
||||
{account.warnings.map((w, i) => (
|
||||
<li key={i}>⚠ {w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
import { Hero } from "../components/Hero";
|
||||
import { Scanner } from "../components/Scanner";
|
||||
import { HowItWorks } from "../components/HowItWorks";
|
||||
import { Features } from "../components/Features";
|
||||
import { Footer } from "../components/Footer";
|
||||
|
||||
export default function HomePage() {
|
||||
const { publicKey } = useWallet();
|
||||
const [scan, setScan] = useState<ScanResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const wallet = publicKey?.toBase58() ?? null;
|
||||
|
||||
const runScan = useCallback(async () => {
|
||||
if (!wallet) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setScan(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/scan`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ wallet }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Scan failed (${res.status})`);
|
||||
}
|
||||
const data = (await res.json()) as ScanResponse;
|
||||
setScan(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Scan failed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [wallet]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<TokenClassification, TokenAccountDto[]>();
|
||||
if (scan) {
|
||||
for (const acct of scan.accounts) {
|
||||
const bucket = map.get(acct.classification) ?? [];
|
||||
bucket.push(acct);
|
||||
map.set(acct.classification, bucket);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [scan]);
|
||||
|
||||
const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0;
|
||||
const { connected } = useWallet();
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="hero">
|
||||
<h1 className="hero__title">PYRE</h1>
|
||||
<p className="hero__tagline">
|
||||
Burn the dead. Feed the PYRE. Claim the Spawn.
|
||||
</p>
|
||||
<p className="hero__trust">
|
||||
PYRE returns your rent. The scraps feed the fire.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="connect">
|
||||
<WalletMultiButton />
|
||||
{wallet && (
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn"
|
||||
onClick={runScan}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Scanning…" : "Scan wallet"}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!wallet && (
|
||||
<p className="hint">Connect a wallet to scan it for reclaimable rent.</p>
|
||||
)}
|
||||
|
||||
{error && <p className="error">Something went wrong: {error}</p>}
|
||||
|
||||
{scan && (
|
||||
<section className="results">
|
||||
<div className="reclaim">
|
||||
<p className="reclaim__headline">
|
||||
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
|
||||
{scan.summary.emptyCloseOnly} empty account
|
||||
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
|
||||
</p>
|
||||
<p className="reclaim__sub">
|
||||
Scanned {scan.summary.totalAccounts} token account
|
||||
{scan.summary.totalAccounts === 1 ? "" : "s"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{SECTIONS.map(({ classification, heading, blurb }) => {
|
||||
const accounts = grouped.get(classification) ?? [];
|
||||
if (accounts.length === 0) return null;
|
||||
return (
|
||||
<div key={classification} className="result-section">
|
||||
<h2 className="result-section__heading">
|
||||
{heading}{" "}
|
||||
<span className="result-section__count">
|
||||
({accounts.length})
|
||||
</span>
|
||||
</h2>
|
||||
<p className="result-section__blurb">{blurb}</p>
|
||||
<ul className="account-list">
|
||||
{accounts.map((a) => (
|
||||
<AccountRow key={a.ata} account={a} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="preview-note">
|
||||
This is a scan and preview only — nothing has been signed. Closing
|
||||
empty accounts and burning junk (which require signing in your
|
||||
wallet) comes next.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
<Hero connected={connected} />
|
||||
<Scanner />
|
||||
<HowItWorks />
|
||||
<Features />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
68
apps/web/src/components/Features.tsx
Normal file
68
apps/web/src/components/Features.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Feature / status cards. HONEST about what is live vs coming soon — coming-soon
|
||||
* items carry a clear badge and must not imply they work today.
|
||||
* Server component (static content, no hooks).
|
||||
*/
|
||||
type Status = "live" | "partial" | "soon";
|
||||
|
||||
const FEATURES: ReadonlyArray<{
|
||||
title: string;
|
||||
body: string;
|
||||
status: Status;
|
||||
statusLabel: string;
|
||||
}> = [
|
||||
{
|
||||
title: "Scan & classify",
|
||||
body: "Read your SPL token accounts and conservatively classify every one. Unknown means skip.",
|
||||
status: "live",
|
||||
statusLabel: "Live",
|
||||
},
|
||||
{
|
||||
title: "Reclaim ATA rent",
|
||||
body: "The scan already estimates recoverable rent from empty accounts. One-click close to send that SOL back to you is next.",
|
||||
status: "partial",
|
||||
statusLabel: "Scan live · close soon",
|
||||
},
|
||||
{
|
||||
title: "Burn dead tokens",
|
||||
body: "Burn worthless leftover balances to zero, then close the emptied accounts to recover their rent.",
|
||||
status: "soon",
|
||||
statusLabel: "Phase 3",
|
||||
},
|
||||
{
|
||||
title: "Prometheus AI meme rebirth",
|
||||
body: "Turn burned remnants into an AI-generated meme Spawn for human-reviewed launch. Entertainment, not a promise of value.",
|
||||
status: "soon",
|
||||
statusLabel: "Coming soon",
|
||||
},
|
||||
];
|
||||
|
||||
function badgeClass(status: Status): string {
|
||||
if (status === "live") return "badge badge--live";
|
||||
if (status === "partial") return "badge badge--partial";
|
||||
return "badge badge--soon";
|
||||
}
|
||||
|
||||
export function Features() {
|
||||
return (
|
||||
<section className="features" aria-labelledby="features-heading">
|
||||
<h2 className="section-heading" id="features-heading">
|
||||
What's live, what's coming
|
||||
</h2>
|
||||
<div className="features__grid">
|
||||
{FEATURES.map(({ title, body, status, statusLabel }) => (
|
||||
<article
|
||||
key={title}
|
||||
className={`feature-card feature-card--${status}`}
|
||||
>
|
||||
<div className="feature-card__top">
|
||||
<h3 className="feature-card__title">{title}</h3>
|
||||
<span className={badgeClass(status)}>{statusLabel}</span>
|
||||
</div>
|
||||
<p className="feature-card__body">{body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
apps/web/src/components/Footer.tsx
Normal file
33
apps/web/src/components/Footer.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Footer: dev status tracker (served separately by nginx as a static /status
|
||||
* page — plain <a>, NOT a Next route), repo link, and the §2 disclaimer.
|
||||
* Server component.
|
||||
*/
|
||||
const REPO_URL = "https://git.lumiai.dev/RogueWave/pyre";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer__brand">
|
||||
<span className="footer__wordmark">PYRE</span>
|
||||
<span className="footer__motto">
|
||||
Burn the dead. Feed the PYRE. Claim the Spawn.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className="footer__links" aria-label="Footer">
|
||||
<a href="/status">Dev status</a>
|
||||
<a href={REPO_URL} target="_blank" rel="noreferrer noopener">
|
||||
Repository
|
||||
</a>
|
||||
<a href="#scanner">Scanner</a>
|
||||
</nav>
|
||||
|
||||
<p className="footer__disclaimer">
|
||||
PYRE is not an investment product, yield mechanism, trading bot, or
|
||||
guaranteed-profit system. It is wallet-cleanup utility and ritual
|
||||
entertainment. Recovered rent returns to you.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
38
apps/web/src/components/Hero.tsx
Normal file
38
apps/web/src/components/Hero.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
|
||||
|
||||
/**
|
||||
* Landing hero: wordmark, tagline, value prop, trust line, and the primary
|
||||
* wallet call-to-action. Wording rules apply — this is a scan/preview only.
|
||||
*/
|
||||
export function Hero({ connected }: { connected: boolean }) {
|
||||
return (
|
||||
<header className="hero">
|
||||
<div className="hero__glow" aria-hidden="true" />
|
||||
<p className="hero__eyebrow">Solana wallet cleanup ritual</p>
|
||||
<h1 className="hero__title">PYRE</h1>
|
||||
<p className="hero__tagline">
|
||||
Burn the dead. Feed the PYRE. Claim the Spawn.
|
||||
</p>
|
||||
<p className="hero__value">
|
||||
Clean dead Solana token accounts and reclaim the SOL rent trapped in
|
||||
them.
|
||||
</p>
|
||||
<p className="hero__trust">
|
||||
PYRE returns your rent. The scraps feed the fire.
|
||||
</p>
|
||||
|
||||
<div className="hero__cta">
|
||||
<WalletMultiButton />
|
||||
<a className="hero__secondary" href="#scanner">
|
||||
{connected ? "Go to scanner" : "See how it works"}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="hero__note">
|
||||
Read-only scan. Nothing is signed — you stay in control of your wallet.
|
||||
</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/components/HowItWorks.tsx
Normal file
55
apps/web/src/components/HowItWorks.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* "How it works" — a 3-4 step visual walking the user from connect to reclaim.
|
||||
* Honest about phase boundaries: closing/burning is flagged as coming soon.
|
||||
* Server component (no hooks / interactivity).
|
||||
*/
|
||||
const STEPS: ReadonlyArray<{
|
||||
step: string;
|
||||
title: string;
|
||||
body: string;
|
||||
soon?: boolean;
|
||||
}> = [
|
||||
{
|
||||
step: "01",
|
||||
title: "Connect",
|
||||
body: "Connect a Solana wallet. PYRE never holds your keys — every action stays client-side.",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "Scan",
|
||||
body: "PYRE reads your SPL token accounts and estimates the SOL rent locked inside empty ones.",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "Review classifications",
|
||||
body: "Each account is conservatively grouped — closeable, burnable, transmutable, or skipped / not eligible.",
|
||||
},
|
||||
{
|
||||
step: "04",
|
||||
title: "Reclaim rent",
|
||||
body: "Close empty accounts and burn dead tokens to recover rent to your own wallet.",
|
||||
soon: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorks() {
|
||||
return (
|
||||
<section className="how" id="how-it-works" aria-labelledby="how-heading">
|
||||
<h2 className="section-heading" id="how-heading">
|
||||
How it works
|
||||
</h2>
|
||||
<ol className="how__grid">
|
||||
{STEPS.map(({ step, title, body, soon }) => (
|
||||
<li key={step} className="how__step">
|
||||
<span className="how__num">{step}</span>
|
||||
<h3 className="how__title">
|
||||
{title}
|
||||
{soon && <span className="badge badge--soon">Coming soon</span>}
|
||||
</h3>
|
||||
<p className="how__body">{body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
221
apps/web/src/components/Scanner.tsx
Normal file
221
apps/web/src/components/Scanner.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useWallet } from "@solana/wallet-adapter-react";
|
||||
import { WalletMultiButton } from "@solana/wallet-adapter-react-ui";
|
||||
import { TokenClassification } from "@pyre/core";
|
||||
import type { ScanResponse, TokenAccountDto } from "@pyre/core";
|
||||
|
||||
// Same-origin by default so production hits "/api/scan" behind the same host.
|
||||
// Override with NEXT_PUBLIC_API_URL only when the API lives elsewhere (e.g. dev).
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||
|
||||
// Display order + friendly headings. Wording rules: never say "safe"; protected /
|
||||
// unsupported read as "skipped / not eligible"; eligible reads as "appears eligible".
|
||||
const SECTIONS: ReadonlyArray<{
|
||||
classification: TokenClassification;
|
||||
heading: string;
|
||||
blurb: string;
|
||||
}> = [
|
||||
{
|
||||
classification: TokenClassification.EMPTY_CLOSE_ONLY,
|
||||
heading: "Closeable empty accounts",
|
||||
blurb: "Empty token accounts that appear eligible to close and reclaim rent.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.INCINERATE_ONLY,
|
||||
heading: "Burnable junk",
|
||||
blurb: "Worthless leftover balances that appear eligible to burn.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.TRANSMUTABLE,
|
||||
heading: "Transmutable scraps",
|
||||
blurb: "Scraps that appear eligible to feed the fire.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.PROTECTED_SKIP,
|
||||
heading: "Protected / skipped",
|
||||
blurb: "Skipped — not eligible for cleanup. Left untouched.",
|
||||
},
|
||||
{
|
||||
classification: TokenClassification.UNSUPPORTED,
|
||||
heading: "Unsupported",
|
||||
blurb: "Not eligible — PYRE can't reason about these, so they're skipped.",
|
||||
},
|
||||
];
|
||||
|
||||
function truncate(addr: string): string {
|
||||
if (addr.length <= 10) return addr;
|
||||
return `${addr.slice(0, 4)}…${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
function lamportsToSol(lamports: string): number {
|
||||
// Lamports arrive as a u64 string; BigInt avoids precision loss before scaling.
|
||||
try {
|
||||
return Number(BigInt(lamports)) / 1e9;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function AccountRow({ account }: { account: TokenAccountDto }) {
|
||||
const label = account.symbol ?? account.name ?? truncate(account.mint);
|
||||
return (
|
||||
<li className="account-row">
|
||||
<div className="account-row__main">
|
||||
<span className="account-row__label" title={account.mint}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="account-row__mint" title={account.mint}>
|
||||
{truncate(account.mint)}
|
||||
</span>
|
||||
<span className="account-row__balance">{account.uiBalance}</span>
|
||||
</div>
|
||||
{account.warnings.length > 0 && (
|
||||
<ul className="account-row__warnings">
|
||||
{account.warnings.map((w, i) => (
|
||||
<li key={i}>⚠ {w}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function Scanner() {
|
||||
const { publicKey } = useWallet();
|
||||
const [scan, setScan] = useState<ScanResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const wallet = publicKey?.toBase58() ?? null;
|
||||
|
||||
const runScan = useCallback(async () => {
|
||||
if (!wallet) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setScan(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/scan`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ wallet }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Scan failed (${res.status})`);
|
||||
}
|
||||
const data = (await res.json()) as ScanResponse;
|
||||
setScan(data);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Scan failed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [wallet]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<TokenClassification, TokenAccountDto[]>();
|
||||
if (scan) {
|
||||
for (const acct of scan.accounts) {
|
||||
const bucket = map.get(acct.classification) ?? [];
|
||||
bucket.push(acct);
|
||||
map.set(acct.classification, bucket);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [scan]);
|
||||
|
||||
const reclaimSol = scan ? lamportsToSol(scan.summary.estimatedRentLamports) : 0;
|
||||
|
||||
return (
|
||||
<section className="scanner" id="scanner" aria-labelledby="scanner-heading">
|
||||
<div className="scanner__head">
|
||||
<h2 className="section-heading" id="scanner-heading">
|
||||
Live scanner
|
||||
</h2>
|
||||
<p className="scanner__sub">
|
||||
Connect a wallet and run a read-only scan for reclaimable rent.
|
||||
Nothing is signed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="scanner__panel">
|
||||
<div className="connect">
|
||||
<WalletMultiButton />
|
||||
{wallet && (
|
||||
<button
|
||||
type="button"
|
||||
className="scan-btn"
|
||||
onClick={runScan}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Scanning…" : "Scan wallet"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!wallet && (
|
||||
<p className="hint">
|
||||
Connect a wallet above to scan it for reclaimable rent.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="scanner__loading" role="status" aria-live="polite">
|
||||
<span className="scanner__spinner" aria-hidden="true" />
|
||||
Reading token accounts…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
Something went wrong: {error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scan && (
|
||||
<div className="results">
|
||||
<div className="reclaim">
|
||||
<p className="reclaim__headline">
|
||||
Recover ~{reclaimSol.toFixed(6)} SOL from{" "}
|
||||
{scan.summary.emptyCloseOnly} empty account
|
||||
{scan.summary.emptyCloseOnly === 1 ? "" : "s"}.
|
||||
</p>
|
||||
<p className="reclaim__sub">
|
||||
Scanned {scan.summary.totalAccounts} token account
|
||||
{scan.summary.totalAccounts === 1 ? "" : "s"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{SECTIONS.map(({ classification, heading, blurb }) => {
|
||||
const accounts = grouped.get(classification) ?? [];
|
||||
if (accounts.length === 0) return null;
|
||||
return (
|
||||
<div key={classification} className="result-section">
|
||||
<h3 className="result-section__heading">
|
||||
{heading}{" "}
|
||||
<span className="result-section__count">
|
||||
({accounts.length})
|
||||
</span>
|
||||
</h3>
|
||||
<p className="result-section__blurb">{blurb}</p>
|
||||
<ul className="account-list">
|
||||
{accounts.map((a) => (
|
||||
<AccountRow key={a.ata} account={a} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<p className="preview-note">
|
||||
This is a scan and preview only — nothing has been signed. Closing
|
||||
empty accounts and burning junk (which require signing in your
|
||||
wallet) comes next.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,82 @@
|
||||
// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config
|
||||
//
|
||||
// ⚠️ INERT / NOT YET RUNNABLE: the apps are NOT implemented yet. This file
|
||||
// defines how the three PYRE processes WILL run once apps/web, apps/api,
|
||||
// and apps/worker are built/deployed. Starting it now will fail because
|
||||
// the apps (and their builds) do not exist yet.
|
||||
// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config.
|
||||
//
|
||||
// Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker.
|
||||
//
|
||||
// Once apps exist, start with:
|
||||
// pm2 start ecosystem.config.cjs
|
||||
// pm2 save # persist process list so `pm2 resurrect` works on boot
|
||||
// Start (from repo root): pm2 start ecosystem.config.cjs --only pyre-api,pyre-web
|
||||
// Persist for boot: pm2 save (systemd unit infra/systemd/pm2-pyre.service
|
||||
// runs `pm2 resurrect` on boot)
|
||||
//
|
||||
// PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2
|
||||
// Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre).
|
||||
// Each process is capped at 400M (8GB VPS shared with postgres/redis/nginx).
|
||||
//
|
||||
// Note on memory: the 8GB VPS is shared with postgres, redis, nginx, etc.,
|
||||
// so each process is capped at 400M via max_memory_restart.
|
||||
// NOTE: api/worker run their TypeScript directly via `node --import tsx` (no
|
||||
// separate build step; tsx resolves the workspace `@pyre/*` source packages).
|
||||
// The web app runs Next.js in production mode and requires a prior `next build`.
|
||||
|
||||
const REPO = __dirname;
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
// Next.js frontend (production) — port 3000 per .env.example (WEB_PORT).
|
||||
// Requires `pnpm --filter @pyre/web build` first.
|
||||
name: "pyre-web",
|
||||
cwd: "apps/web",
|
||||
script: "pnpm",
|
||||
args: "start", // runs `next start` (requires a prior `pnpm build`)
|
||||
cwd: `${REPO}/apps/web`,
|
||||
script: "node_modules/next/dist/bin/next",
|
||||
args: "start",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_memory_restart: "400M",
|
||||
max_memory_restart: "500M",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: 3000,
|
||||
HOSTNAME: "127.0.0.1",
|
||||
},
|
||||
out_file: "/home/pyre/pyre/logs/pyre-web-out.log",
|
||||
error_file: "/home/pyre/pyre/logs/pyre-web-err.log",
|
||||
out_file: `${REPO}/logs/pyre-web-out.log`,
|
||||
error_file: `${REPO}/logs/pyre-web-err.log`,
|
||||
},
|
||||
{
|
||||
// Fastify HTTP API — port 4000 per .env.example (API_PORT).
|
||||
// Runs the compiled server. Until a build exists you can temporarily
|
||||
// swap to a dev runner: script: "pnpm", args: "dev"
|
||||
// Runs TS directly via tsx (resolves workspace @pyre/* source).
|
||||
name: "pyre-api",
|
||||
cwd: "apps/api",
|
||||
script: "node",
|
||||
args: "dist/index.js",
|
||||
cwd: `${REPO}/apps/api`,
|
||||
script: "src/index.ts",
|
||||
interpreter: "node",
|
||||
interpreter_args: "--import tsx",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_memory_restart: "400M",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
PORT: 4000,
|
||||
HOST: "127.0.0.1",
|
||||
WEB_PUBLIC_URL: "https://feedthepyre.com",
|
||||
// Public RPC by default — override with a Helius/Triton/QuickNode URL
|
||||
// (set SOLANA_RPC_URL in the environment) to avoid mainnet rate limits.
|
||||
SOLANA_RPC_URL: "https://api.mainnet-beta.solana.com",
|
||||
},
|
||||
out_file: "/home/pyre/pyre/logs/pyre-api-out.log",
|
||||
error_file: "/home/pyre/pyre/logs/pyre-api-err.log",
|
||||
out_file: `${REPO}/logs/pyre-api-out.log`,
|
||||
error_file: `${REPO}/logs/pyre-api-err.log`,
|
||||
},
|
||||
{
|
||||
// BullMQ background worker (no HTTP port).
|
||||
// Runs the compiled worker. Until a build exists you can temporarily
|
||||
// swap to a dev runner: script: "pnpm", args: "dev"
|
||||
// BullMQ background worker (no HTTP port). Not started by default in
|
||||
// Phase 1 (no jobs implemented yet). Runs TS via tsx when enabled.
|
||||
name: "pyre-worker",
|
||||
cwd: "apps/worker",
|
||||
script: "node",
|
||||
args: "dist/index.js",
|
||||
cwd: `${REPO}/apps/worker`,
|
||||
script: "src/index.ts",
|
||||
interpreter: "node",
|
||||
interpreter_args: "--import tsx",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_memory_restart: "400M",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
out_file: "/home/pyre/pyre/logs/pyre-worker-out.log",
|
||||
error_file: "/home/pyre/pyre/logs/pyre-worker-err.log",
|
||||
out_file: `${REPO}/logs/pyre-worker-out.log`,
|
||||
error_file: `${REPO}/logs/pyre-worker-err.log`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,109 +1,63 @@
|
||||
# ============================================================================
|
||||
# PYRE / Prometheus Protocol — nginx virtual host for feedthepyre.com
|
||||
# ----------------------------------------------------------------------------
|
||||
# Install path: /etc/nginx/sites-available/feedthepyre.com
|
||||
# (the provision script symlinks this into sites-enabled/)
|
||||
# =============================================================================
|
||||
# nginx vhost — feedthepyre.com
|
||||
# =============================================================================
|
||||
# Serves the PYRE app at /, the API at /api, and the dev status tracker at
|
||||
# /status. Installed to /etc/nginx/sites-available/feedthepyre.com by
|
||||
# scripts/deploy-web.sh; certbot --nginx mirrors this server to a 443 block and
|
||||
# adds the HTTP->HTTPS redirect.
|
||||
#
|
||||
# TLS: Managed by certbot. Run `certbot --nginx` AFTER this config is
|
||||
# installed — it will inject the listen 443 ssl server block,
|
||||
# the ssl_certificate / ssl_certificate_key lines, and the
|
||||
# HTTP->HTTPS redirect automatically. Do NOT hand-edit those in.
|
||||
#
|
||||
# App ports (see docs/PYRE_MVP_DESIGN.md §11 and .env.example):
|
||||
# web (Next.js) -> 127.0.0.1:3000 (WEB_PORT)
|
||||
# api (Fastify) -> 127.0.0.1:4000 (API_PORT)
|
||||
#
|
||||
# Current behaviour: serves the static status dashboard from
|
||||
# /var/www/feedthepyre/status. The reverse-proxy blocks below
|
||||
# are commented out until the apps are deployed.
|
||||
# ============================================================================
|
||||
# Upstreams (pm2): web = 127.0.0.1:3000 (Next.js), api = 127.0.0.1:4000 (Fastify)
|
||||
# =============================================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name feedthepyre.com www.feedthepyre.com;
|
||||
|
||||
# --- Static status site (current site root) -----------------------------
|
||||
root /var/www/feedthepyre/status;
|
||||
index index.html;
|
||||
|
||||
# --- Logging ------------------------------------------------------------
|
||||
access_log /var/log/nginx/feedthepyre.access.log;
|
||||
error_log /var/log/nginx/feedthepyre.error.log;
|
||||
|
||||
# --- ACME HTTP-01 challenge --------------------------------------------
|
||||
# Explicit so certbot's HTTP-01 validation works even before its --nginx
|
||||
# tweaks are applied. ^~ ensures this wins over the regex/proxy locations.
|
||||
gzip on;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css application/json application/javascript
|
||||
application/xml image/svg+xml;
|
||||
|
||||
client_max_body_size 1m;
|
||||
|
||||
# Let's Encrypt HTTP-01 (kept so cert renewals work).
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/feedthepyre/status;
|
||||
allow all;
|
||||
}
|
||||
|
||||
# --- Basic hardening ----------------------------------------------------
|
||||
# gzip for text-ish content types.
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 256;
|
||||
gzip_proxied any;
|
||||
gzip_vary on;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/rss+xml
|
||||
image/svg+xml;
|
||||
|
||||
# NOTE: `server_tokens off;` is intentionally NOT set here — it belongs in
|
||||
# the http{} block of /etc/nginx/nginx.conf so it applies globally. Set it
|
||||
# there once rather than duplicating it per-vhost.
|
||||
|
||||
# --- Site root ----------------------------------------------------------
|
||||
# Serve the static status dashboard for now.
|
||||
#
|
||||
# LATER: when apps/web (Next.js) is deployed, switch this location from the
|
||||
# static status page to a reverse proxy. Replace the try_files body with:
|
||||
#
|
||||
# proxy_pass http://127.0.0.1:3000;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection $connection_upgrade;
|
||||
#
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# --- Dev status tracker (static) -> /status -----------------------------
|
||||
location = /status { return 301 /status/; }
|
||||
location /status/ {
|
||||
alias /var/www/feedthepyre/status/;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /status/index.html;
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------------
|
||||
# REVERSE-PROXY BLOCKS — enable when apps are running
|
||||
# ------------------------------------------------------------------------
|
||||
# Uncomment the /api/ block below once apps/api (Fastify, port 4000) is up.
|
||||
# The trailing slash on proxy_pass strips the /api/ prefix so the backend
|
||||
# sees /scan, /receipt, etc.
|
||||
#
|
||||
# location /api/ {
|
||||
# proxy_pass http://127.0.0.1:4000/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Host $host;
|
||||
# proxy_set_header X-Real-IP $remote_addr;
|
||||
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# proxy_set_header X-Forwarded-Proto $scheme;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection $connection_upgrade;
|
||||
# }
|
||||
#
|
||||
# The websocket Upgrade/Connection headers above rely on a $connection_upgrade
|
||||
# map. Add this once in the http{} block of /etc/nginx/nginx.conf:
|
||||
#
|
||||
# map $http_upgrade $connection_upgrade {
|
||||
# default upgrade;
|
||||
# '' close;
|
||||
# }
|
||||
# ------------------------------------------------------------------------
|
||||
# --- API (Fastify) ------------------------------------------------------
|
||||
# No trailing slash on proxy_pass: the /api/ prefix is preserved, so the
|
||||
# backend receives /api/scan (its actual route).
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# --- Web app (Next.js) --------------------------------------------------
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
45
scripts/deploy-web.sh
Executable file
45
scripts/deploy-web.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Cut nginx over to serve the PYRE app at / (and the dev tracker at /status).
|
||||
# =============================================================================
|
||||
# Prereqs: the app is built + running under pm2 (pyre-web:3000, pyre-api:4000),
|
||||
# and Phase 0 provisioning already obtained a TLS cert for feedthepyre.com.
|
||||
#
|
||||
# Run as root: sudo bash scripts/deploy-web.sh
|
||||
# Idempotent + re-runnable.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="feedthepyre.com"
|
||||
WWW_DOMAIN="www.feedthepyre.com"
|
||||
REPO_DIR="/home/pyre/pyre"
|
||||
CERTBOT_EMAIL="${CERTBOT_EMAIL:-a31s15.roguewave@gmail.com}"
|
||||
VHOST_SRC="${REPO_DIR}/infra/nginx/${DOMAIN}.conf"
|
||||
VHOST_AVAIL="/etc/nginx/sites-available/${DOMAIN}"
|
||||
VHOST_ENABLED="/etc/nginx/sites-enabled/${DOMAIN}"
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "Must run as root: sudo bash ${0}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Installing nginx vhost (app at / , tracker at /status , api at /api)"
|
||||
install -m 0644 "${VHOST_SRC}" "${VHOST_AVAIL}"
|
||||
ln -sfn "${VHOST_AVAIL}" "${VHOST_ENABLED}"
|
||||
|
||||
echo "==> nginx -t"
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> Re-applying TLS (certbot mirrors the server to a 443 block; idempotent)"
|
||||
certbot --nginx -d "${DOMAIN}" -d "${WWW_DOMAIN}" \
|
||||
--non-interactive --agree-tos -m "${CERTBOT_EMAIL}" \
|
||||
--redirect --keep-until-expiring || {
|
||||
echo "[WARN] certbot did not complete; HTTP is live, re-run once DNS/cert is ready." >&2
|
||||
}
|
||||
systemctl reload nginx
|
||||
|
||||
echo "Done."
|
||||
echo " https://${DOMAIN}/ -> PYRE app (pm2 pyre-web:3000)"
|
||||
echo " https://${DOMAIN}/api/scan -> API (pm2 pyre-api:4000)"
|
||||
echo " https://${DOMAIN}/status/ -> dev status tracker (static)"
|
||||
Reference in New Issue
Block a user