Update market, account, and onion operations

Capture the current CyberLux UI, commerce, messaging, and Tor ops updates so local main can be pushed to the remote.

Made-with: Cursor
This commit is contained in:
drjones
2026-04-24 23:23:48 -07:00
parent cb072437d0
commit 04d64fb993
44 changed files with 2256 additions and 645 deletions

4
.gitignore vendored
View File

@@ -42,3 +42,7 @@ next-env.d.ts
# runtime-generated onion URL file (populated by scripts/export-onion-urls.sh)
onion-urls.txt
# runtime data
.messages.json
/logs/

View File

@@ -74,6 +74,34 @@ sudo node scripts/onion-status.cjs
3. `sudo bash scripts/install-tor-onion.sh`
4. Rebuild/restart the app: `npm run build` and `sudo systemctl restart cyberlux.service`
## 502 Bad Gateway on `.onion` sites
Tor and nginx are working, but **nginx proxies to Next.js on `127.0.0.1:3000`**. A **502** means **nothing is listening there** (Next is stopped, crashed, or never started after reboot).
1. **Confirm** (from the repo):
```bash
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/
```
`000` = connection refused → Next is down.
2. **Start Next** (pick one):
- **Foreground (dev / quick test):** `cd /path/to/cyberlux && npm run start:onion` — leave the terminal open.
- **systemd (production):** `sudo systemctl start cyberlux.service` — ensure the unit is installed (`scripts/install-systemd.sh`) and enabled.
- **Full stack script:** `./start.sh` (builds, configures Tor/nginx if needed, then starts Next).
3. **Verify again:**
```bash
npm run health:stack
```
You want `Next.js: OK` and `hub vhost: OK` (HTTP 200/301/302/304).
4. **If it still fails:** `journalctl -u cyberlux.service -n 80 --no-pager` — look for crash loops, missing `.next` (run `npm run build`), or wrong `WorkingDirectory` in the unit.
## Verification
```bash

59
app/account/page.tsx Normal file
View File

@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { useAccount } from "@/contexts/AccountContext";
export default function AccountHubPage() {
const { user, hydrated } = useAccount();
return (
<div className="min-h-screen bg-[#0a0a0a] px-4 py-14 text-zinc-200">
<div className="mx-auto max-w-lg">
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-emerald-500/80">account</p>
<h1 className="mt-3 font-orbitron text-2xl font-bold text-zinc-100">Session & mirrors</h1>
<p className="mt-4 text-sm leading-relaxed text-zinc-500">
This host stores handles, balances, and forum keys in your browser. Use the same links on any CyberLux
.onion import a bundle on each hostname if you want one identity everywhere.
</p>
<ul className="mt-8 space-y-2 text-sm text-zinc-300">
<li>
<Link href="/dashboard" className="text-emerald-400 hover:underline">
Dashboard
</Link>{" "}
activity, profile, vendor queue
</li>
<li>
<Link href="/account/add-funds" className="text-emerald-400 hover:underline">
Add funds
</Link>{" "}
Bitcoin USD store credit
</li>
<li>
<Link href="/account/hidden-services" className="text-emerald-400 hover:underline">
Onion mirror map
</Link>{" "}
export / import identity bundle
</li>
<li>
<Link href="/sign-in" className="text-emerald-400 hover:underline">
Sign in
</Link>{" "}
·{" "}
<Link href="/sign-up" className="text-emerald-400 hover:underline">
Sign up
</Link>
</li>
</ul>
{hydrated && user ? (
<p className="mt-8 font-mono text-xs text-zinc-500">
Signed in as <span className="text-emerald-200/90">@{user.username}</span>
</p>
) : hydrated ? (
<p className="mt-8 text-xs text-zinc-600">Not signed in on this hostname.</p>
) : null}
</div>
</div>
);
}

79
app/api/messages/route.ts Normal file
View File

@@ -0,0 +1,79 @@
import { NextResponse } from "next/server";
import { promises as fs } from "fs";
import path from "path";
const MESSAGES_FILE = path.join(process.cwd(), ".messages.json");
type Msg = { id: string; from: string; text: string; ts: number; channel?: string };
async function getMessages(): Promise<Msg[]> {
try {
const data = await fs.readFile(MESSAGES_FILE, "utf-8");
return JSON.parse(data) as Msg[];
} catch {
return [];
}
}
async function saveMessages(msgs: Msg[]) {
const toSave = msgs.slice(-500);
await fs.writeFile(MESSAGES_FILE, JSON.stringify(toSave, null, 2), "utf-8");
}
export async function GET(req: Request) {
const url = new URL(req.url);
const channel = url.searchParams.get("channel") || "global";
const msgs = await getMessages();
const channelMsgs = msgs.filter((m) => m.channel === channel || channel === "all");
return NextResponse.json({ ok: true, messages: channelMsgs });
}
export async function POST(req: Request) {
try {
const body = await req.json();
const { from, text, channel = "global" } = body;
if (!from || !text) {
return NextResponse.json({ ok: false, error: "Missing fields" }, { status: 400 });
}
const newMsg: Msg = {
id: Math.random().toString(36).slice(2, 10),
from: from.trim(),
text: text.trim().slice(0, 500),
ts: Date.now(),
channel,
};
const msgs = await getMessages();
msgs.push(newMsg);
await saveMessages(msgs);
return NextResponse.json({ ok: true, message: newMsg });
} catch (err) {
return NextResponse.json({ ok: false, error: "Failed to save message" }, { status: 500 });
}
}
export async function DELETE(req: Request) {
try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
const clearAll = url.searchParams.get("all") === "true";
let msgs = await getMessages();
if (clearAll) {
msgs = [];
} else if (id) {
msgs = msgs.filter((m) => m.id !== id);
} else {
return NextResponse.json({ ok: false, error: "Provide id or all=true" }, { status: 400 });
}
await saveMessages(msgs);
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ ok: false, error: "Failed to delete" }, { status: 500 });
}
}

View File

@@ -1,22 +1,22 @@
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #f0f0f0;
--primary: #8b00ff;
--secondary: #00e0ff;
--background: #030008;
--foreground: #e6ffed;
--primary: #00ff66;
--secondary: #9000ff;
--accent: #ff0055;
--muted: #1a1a1a;
--card: rgba(20, 20, 30, 0.8);
--glass: rgba(255, 255, 255, 0.05);
--glass-strong: rgba(255, 255, 255, 0.075);
--ring: rgba(0, 255, 255, 0.35);
--shadow-soft: 0 18px 60px rgba(0, 0, 0, 0.55);
--shadow-glass: 0 10px 40px rgba(0, 0, 0, 0.35);
--neon-cyan: #00ffff;
--neon-purple: #9d00ff;
--neon-pink: #ff00ff;
--neon-green: #00ff9d;
--muted: #0d1210;
--card: rgba(5, 10, 5, 0.85);
--glass: rgba(0, 255, 100, 0.04);
--glass-strong: rgba(144, 0, 255, 0.08);
--ring: rgba(0, 255, 102, 0.35);
--shadow-soft: 0 18px 60px rgba(0, 255, 102, 0.15);
--shadow-glass: 0 10px 40px rgba(144, 0, 255, 0.15);
--neon-cyan: #00ff66;
--neon-purple: #9000ff;
--neon-pink: #ff0066;
--neon-green: #b3ff00;
}
@theme inline {
@@ -65,11 +65,11 @@ body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-image:
radial-gradient(1200px 700px at 15% 20%, rgba(157, 0, 255, 0.18) 0%, transparent 55%),
radial-gradient(900px 600px at 85% 65%, rgba(0, 255, 255, 0.16) 0%, transparent 55%),
radial-gradient(700px 450px at 65% 15%, rgba(255, 0, 255, 0.10) 0%, transparent 50%),
radial-gradient(900px 700px at 30% 85%, rgba(0, 255, 157, 0.10) 0%, transparent 55%),
linear-gradient(180deg, rgba(255,255,255,0.03), transparent 55%);
radial-gradient(1200px 700px at 15% 20%, rgba(144, 0, 255, 0.18) 0%, transparent 55%),
radial-gradient(900px 600px at 85% 65%, rgba(0, 255, 102, 0.14) 0%, transparent 55%),
radial-gradient(700px 450px at 65% 15%, rgba(255, 0, 102, 0.10) 0%, transparent 50%),
radial-gradient(900px 700px at 30% 85%, rgba(179, 255, 0, 0.12) 0%, transparent 55%),
linear-gradient(180deg, rgba(0, 255, 102, 0.03), transparent 55%);
background-attachment: fixed;
}

View File

@@ -137,7 +137,7 @@ const ENTRIES: { cat: string; items: WikiItem[] }[] = [
items: [
{
title: "Launch / deploy notes",
note: "Tor + nginx operator entry.",
note: "Tor + nginx + verify script — operator entry for every .onion on this host.",
href: "/launch",
external: false,
},
@@ -227,6 +227,12 @@ export default function HiddenWikiPage() {
>
All mirrors
</Link>
<Link
href="/launch"
className="border border-[#2a3444] bg-[#0f141c] px-3 py-1.5 text-[#94a8b8] hover:border-[#3d5a80]/50"
>
Launch
</Link>
<Link
href="/syndicate"
className="border border-[#2a3444] bg-[#0f141c] px-3 py-1.5 text-[#94a8b8] hover:border-[#3d5a80]/50"

View File

@@ -18,10 +18,10 @@ const rajdhani = Rajdhani({
export const metadata: Metadata = {
title: {
default: "CyberLux",
template: "%s · CyberLux",
default: "EldritchWeave",
template: "%s · EldritchWeave",
},
description: "Curated storefront, catalog, forums, and Tor companion rails.",
description: "Curated apothecary, grimoires, covenants, and shadow rails.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import ThemedLayout from "@/components/layouts/ThemedLayout";
import { LINK_GARDEN_INTERNAL } from "@/lib/cyberluxSitemap";
type Resource = {
name: string;
@@ -89,19 +90,6 @@ const RESOURCES: Resource[] = [
},
];
const INTERNAL: { name: string; description: string; href: string; icon: string }[] = [
{ name: "Darknet Atlas", description: "Analyst taxonomy of underground ecosystems — every row links to a real CyberLux surface.", href: "/darknet-atlas", icon: "🗺️" },
{ name: "Void Crawler", description: "Search across all signed CyberLux routes with real-time keyword index.", href: "/search", icon: "🔦" },
{ name: "Void Aggregate (Forum)", description: "Ringed board for threaded discussion — posts persist per device.", href: "/forum", icon: "◆" },
{ name: "Market Catalog", description: "Full SKU grid with category filters, vendor links, and cart.", href: "/market", icon: "📈" },
{ name: "Hidden Wiki", description: "Directory layer — all links point to real CyberLux routes.", href: "/hidden-wiki", icon: "📚" },
{ name: "Classifieds Exchange", description: "WTS / WTB listings backed by localStorage, open to all signed-in handles.", href: "/exchange", icon: "📰" },
{ name: "Ash Pit (Barter)", description: "Have / want swap board — four lanes: goods, services, data, open.", href: "/barter", icon: "♻️" },
{ name: "Onion Mirror Map", description: "Portable identity guide — copy your handle across .onion hostnames.", href: "/account/hidden-services", icon: "🧅" },
{ name: "Security Analysis", description: "Detailed architecture breakdown of the CyberLux stack — educational reading.", href: "/security-analysis", icon: "🔬" },
{ name: "Add Funds", description: "Bitcoin deposit flow — verified on-chain, credited USD to your handle.", href: "/account/add-funds", icon: "₿" },
];
const TRUST_LABELS: Record<Resource["trust"], { label: string; color: string }> = {
internal: { label: "Internal", color: "text-neon-green" },
clearnet: { label: "Clearnet", color: "text-neon-cyan" },
@@ -185,7 +173,7 @@ export default function LinksPage() {
<section className="container mx-auto max-w-6xl px-4 py-16">
<h2 className="mb-8 font-orbitron text-3xl font-bold">INTERNAL ROUTES</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{INTERNAL.map((item) => (
{LINK_GARDEN_INTERNAL.map((item) => (
<Link
key={item.href}
href={item.href}

View File

@@ -1,5 +1,16 @@
import { Suspense } from "react";
import MarketSiteChrome from "@/components/market/MarketSiteChrome";
export default function MarketLayout({ children }: { children: React.ReactNode }) {
return <MarketSiteChrome>{children}</MarketSiteChrome>;
return (
<MarketSiteChrome>
<Suspense
fallback={
<div className="py-16 text-center font-mono text-sm text-[#00ff41]/45">Loading catalog</div>
}
>
{children}
</Suspense>
</MarketSiteChrome>
);
}

View File

@@ -2,7 +2,8 @@
import Image from "next/image";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
import { useCart } from "@/contexts/CartContext";
@@ -14,6 +15,7 @@ import {
type ShopCurrency,
type ShopProduct,
} from "@/lib/shopCatalog";
import { estimateUsdForCryptoAmount } from "@/lib/shopFx";
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
import { SHOP_SELLER_COUNT, getSellerById } from "@/lib/shopSellers";
@@ -31,56 +33,129 @@ const SYM: Record<ShopCurrency, string> = {
XMR: "⏣",
};
type SortKey = "relevance" | "price-asc" | "price-desc" | "name";
export default function MarketPage() {
const sp = useSearchParams();
const router = useRouter();
const initialCat = sp.get("category") ?? "";
const [q, setQ] = useState("");
const [category, setCategory] = useState(initialCat);
const [sort, setSort] = useState<SortKey>("relevance");
const [currencyFilter, setCurrencyFilter] = useState<ShopCurrency | "">("");
const [btcUsd, setBtcUsd] = useState<number | null>(null);
const facet = useMemo(() => shopCategoryCounts(), []);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
{ cache: "no-store" },
);
const j = (await res.json()) as { bitcoin?: { usd?: number } };
if (!cancelled && j.bitcoin?.usd && Number.isFinite(j.bitcoin.usd)) setBtcUsd(j.bitcoin.usd);
} catch {
if (!cancelled) setBtcUsd(96000);
}
})();
return () => {
cancelled = true;
};
}, []);
const spotBtc = btcUsd ?? 96000;
const filtered = useMemo(() => {
const ql = q.trim().toLowerCase();
const tokens = ql.split(/\s+/).filter(Boolean);
return SHOP_PRODUCTS.filter((p) => {
if (category && p.category !== category) return false;
if (currencyFilter && p.currency !== currencyFilter) return false;
if (tokens.length === 0) return true;
const seller = getSellerById(p.sellerId);
const sellerHay = seller ? `${seller.handle} ${seller.bio} ${seller.specialty}` : "";
const hay = `${p.name} ${p.description} ${p.category} ${p.id} ${sellerHay}`.toLowerCase();
return tokens.every((t) => hay.includes(t));
});
}, [q, category]);
}, [q, category, currencyFilter]);
const sorted = useMemo(() => {
const list = [...filtered];
const unitUsd = (p: ShopProduct) => estimateUsdForCryptoAmount(p.price, p.currency, spotBtc);
switch (sort) {
case "price-asc":
return list.sort((a, b) => unitUsd(a) - unitUsd(b));
case "price-desc":
return list.sort((a, b) => unitUsd(b) - unitUsd(a));
case "name":
return list.sort((a, b) => a.name.localeCompare(b.name));
default:
return list;
}
}, [filtered, sort, spotBtc]);
return (
<div className="font-mono text-[#00ff41]">
<section className="mb-8 border-b border-[#00ff41]/20 pb-6">
<section className="mb-6 border-b border-[#00ff41]/20 pb-4">
<p className="text-[10px] uppercase tracking-[0.4em] text-[#00ff41]/50">Layer 1 · live floor</p>
<p className="mt-2 max-w-3xl text-sm text-[#00ff41]/65">
<strong className="text-[#7af598]">{SHOP_PRODUCT_COUNT}</strong> SKUs ·{" "}
<Link href="/vendors" className="font-bold text-[#9dffc4] underline hover:text-white">
{SHOP_SELLER_COUNT} vendor stalls
</Link>
per-listing reviews, velocity, and cart handoff into checkout (USD estimate from spot). Use the left rail for ops, trust, and intel layers.
. Every SKU has a detail page with qty, cart, checkout, or instant USD balance payment. Spot:{" "}
<span className="text-[#7af598]">${spotBtc.toLocaleString(undefined, { maximumFractionDigits: 0 })}/BTC</span>.
</p>
</section>
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="flex-1">
<label className="text-[10px] uppercase tracking-widest text-[#00ff41]/50">Search catalog</label>
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="tokens match title, description, category…"
className="mt-1 w-full max-w-xl border-2 border-[#00ff41]/30 bg-black/80 px-4 py-3 text-sm text-[#c8ffd8] placeholder:text-[#00ff41]/25 focus:border-[#00ff41] focus:outline-none"
placeholder="title, description, vendor…"
className="mt-1 w-full max-w-xl border-2 border-[#00ff41]/30 bg-black/80 px-4 py-2.5 text-sm text-[#c8ffd8] placeholder:text-[#00ff41]/25 focus:border-[#00ff41] focus:outline-none"
/>
</div>
<p className="text-xs text-[#00ff41]/50">
Showing <span className="text-[#7af598]">{filtered.length}</span> / {SHOP_PRODUCT_COUNT}
</p>
<div className="flex flex-wrap items-center gap-3 text-xs text-[#00ff41]/50">
<span>
Showing <span className="text-[#7af598]">{sorted.length}</span> / {SHOP_PRODUCT_COUNT}
</span>
<label className="flex items-center gap-2">
<span className="text-[10px] uppercase">Sort</span>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortKey)}
className="border border-[#00ff41]/35 bg-black px-2 py-1 text-[11px] text-[#c8ffd8]"
>
<option value="relevance">Default</option>
<option value="price-asc">Price (USD est.)</option>
<option value="price-desc">Price (USD est.)</option>
<option value="name">Name AZ</option>
</select>
</label>
<label className="flex items-center gap-2">
<span className="text-[10px] uppercase">Currency</span>
<select
value={currencyFilter}
onChange={(e) => setCurrencyFilter((e.target.value || "") as ShopCurrency | "")}
className="border border-[#00ff41]/35 bg-black px-2 py-1 text-[11px] text-[#c8ffd8]"
>
<option value="">All</option>
<option value="BTC">BTC</option>
<option value="ETH">ETH</option>
<option value="USD">USD</option>
<option value="XMR">XMR</option>
</select>
</label>
</div>
</div>
<div className="mb-8 flex flex-wrap gap-2">
<div className="mb-6 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCategory("")}
@@ -102,12 +177,12 @@ export default function MarketPage() {
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{filtered.map((p) => (
<MarketProductCard key={p.id} product={p} />
{sorted.map((p) => (
<MarketProductCard key={p.id} product={p} spotBtc={spotBtc} onBuyNow={() => router.push("/checkout")} />
))}
</div>
{filtered.length === 0 ? (
{sorted.length === 0 ? (
<p className="mt-16 border border-dashed border-[#00ff41]/20 py-12 text-center text-sm text-[#00ff41]/50">
No SKUs match widen search or pick another category.
</p>
@@ -116,17 +191,37 @@ export default function MarketPage() {
);
}
function MarketProductCard({ product: p }: { product: ShopProduct }) {
function MarketProductCard({
product: p,
spotBtc,
onBuyNow,
}: {
product: ShopProduct;
spotBtc: number;
onBuyNow: () => void;
}) {
const src = resolveProductImage(p);
const local = src.startsWith("/");
const v = getSellerById(p.sellerId);
const metrics = getProductSocialMetrics(p.id);
const { addToCart, hydrated } = useCart();
const [flash, setFlash] = useState(false);
const unitUsd = estimateUsdForCryptoAmount(p.price, p.currency, spotBtc);
const add = () => {
addToCart(p, 1);
setFlash(true);
window.setTimeout(() => setFlash(false), 1400);
};
const buyNow = () => {
addToCart(p, 1);
onBuyNow();
};
return (
<article className="flex h-full flex-col border border-[#00ff41]/20 bg-[#0d0d0d] transition-all hover:border-[#00ff41]/55">
<div className="relative aspect-square w-full bg-black">
<Link href={`/market/product/${encodeURIComponent(p.id)}`} className="relative block aspect-square w-full bg-black">
<Image
src={src}
alt=""
@@ -138,10 +233,12 @@ function MarketProductCard({ product: p }: { product: ShopProduct }) {
{p.limited ? (
<span className="absolute left-2 top-2 bg-[#ff00aa] px-2 py-0.5 text-[10px] font-bold uppercase text-black">Limited</span>
) : null}
</div>
</Link>
<div className="flex flex-1 flex-col p-4">
<p className="text-[10px] uppercase tracking-wider text-[#00ff41]/45">{p.category}</p>
<h2 className="mt-1 text-base font-bold leading-snug text-[#d8ffe8]">{p.name}</h2>
<Link href={`/market/product/${encodeURIComponent(p.id)}`}>
<h2 className="mt-1 text-base font-bold leading-snug text-[#d8ffe8] hover:text-[#7af598]">{p.name}</h2>
</Link>
<div className="mt-2 text-[#9dffc4]">
<ProductSocialBlock metrics={metrics} compact />
</div>
@@ -154,30 +251,39 @@ function MarketProductCard({ product: p }: { product: ShopProduct }) {
<span className="text-[#00ff41]/35"> · stall {v.rating.toFixed(2)}</span>
</p>
) : null}
<p className="mt-2 line-clamp-3 text-xs leading-relaxed text-[#00ff41]/70">{p.description}</p>
<blockquote className="mt-3 border-l-2 border-[#00ff41]/30 pl-3 text-[11px] italic leading-snug text-[#b4ffcc]/80">
{metrics.testimonials[0]?.text}
<span className="mt-0.5 block font-mono text-[10px] not-italic text-[#00ff41]/45">
@{metrics.testimonials[0]?.handle} · {metrics.testimonials[0]?.daysAgo}d
</span>
</blockquote>
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-[#00ff41]/15 pt-3">
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-[#00ff41]/70">{p.description}</p>
<div className="mt-3 flex items-baseline justify-between gap-2 border-t border-[#00ff41]/15 pt-3">
<div>
<span className="text-lg font-bold text-[#7af598]">
{SYM[p.currency]}
{formatPrice(p.price, p.currency)} <span className="text-xs font-normal text-[#00ff41]/50">{p.currency}</span>
</span>
<p className="font-mono text-[10px] text-[#5a8f6a]"> ${unitUsd.toFixed(2)} USD</p>
</div>
<Link
href={`/market/product/${encodeURIComponent(p.id)}`}
className="shrink-0 text-[10px] font-bold uppercase text-[#7af598] underline hover:text-white"
>
Details
</Link>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={!hydrated}
onClick={() => {
addToCart(p, 1);
setFlash(true);
window.setTimeout(() => setFlash(false), 1400);
}}
className="border border-[#00ff41] bg-[#00ff41] px-3 py-1.5 text-xs font-bold uppercase text-black hover:bg-[#7af598] disabled:opacity-50"
onClick={add}
className="flex-1 border border-[#00ff41] bg-[#00ff41] px-2 py-1.5 text-[10px] font-bold uppercase text-black hover:bg-[#7af598] disabled:opacity-50"
>
{flash ? "Added" : "Add"}
</button>
<button
type="button"
disabled={!hydrated}
onClick={buyNow}
className="flex-1 border border-[#00ff41]/40 px-2 py-1.5 text-[10px] font-bold uppercase text-[#7af598] hover:bg-[#00ff41]/10 disabled:opacity-50"
>
Buy now
</button>
</div>
</div>
</article>

View File

@@ -0,0 +1,220 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
import PayWithUsdBalanceButton from "@/components/PayWithUsdBalanceButton";
import { useCart } from "@/contexts/CartContext";
import {
getShopProductById,
resolveProductImage,
type ShopCurrency,
} from "@/lib/shopCatalog";
import { estimateUsdForCryptoAmount } from "@/lib/shopFx";
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
import { getSellerById } from "@/lib/shopSellers";
const SYM: Record<ShopCurrency, string> = {
BTC: "₿",
ETH: "Ξ",
USD: "$",
XMR: "⏣",
};
function formatPrice(price: number, currency: ShopCurrency): string {
if (currency === "BTC") return price.toFixed(6);
if (currency === "ETH") return price.toFixed(4);
if (currency === "XMR") return price.toFixed(3);
return price.toFixed(2);
}
export default function MarketProductPage() {
const params = useParams();
const id = typeof params?.id === "string" ? params.id : "";
const router = useRouter();
const product = id ? getShopProductById(id) : undefined;
const [btcUsd, setBtcUsd] = useState<number | null>(null);
const [qty, setQty] = useState(1);
const [addedFlash, setAddedFlash] = useState(false);
const { addToCart, hydrated } = useCart();
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
{ cache: "no-store" },
);
const j = (await res.json()) as { bitcoin?: { usd?: number } };
if (!cancelled && j.bitcoin?.usd && Number.isFinite(j.bitcoin.usd)) setBtcUsd(j.bitcoin.usd);
} catch {
if (!cancelled) setBtcUsd(96000);
}
})();
return () => {
cancelled = true;
};
}, []);
const spot = btcUsd ?? 96000;
const lineUsd = useMemo(() => {
if (!product) return 0;
const unit = estimateUsdForCryptoAmount(product.price, product.currency, spot);
return Math.round(unit * qty * 100) / 100;
}, [product, qty, spot]);
const buyNow = useCallback(() => {
if (!product || !hydrated) return;
addToCart(product, qty);
router.push("/checkout");
}, [product, qty, addToCart, hydrated, router]);
const addOnly = useCallback(() => {
if (!product || !hydrated) return;
addToCart(product, qty);
setAddedFlash(true);
window.setTimeout(() => setAddedFlash(false), 1200);
}, [product, qty, addToCart, hydrated]);
if (!product) {
return (
<div className="border border-dashed border-[#00ff41]/25 py-20 text-center font-mono text-sm text-[#00ff41]/55">
<p>SKU not found.</p>
<Link href="/market" className="mt-4 inline-block text-[#7af598] underline">
Back to catalog
</Link>
</div>
);
}
const src = resolveProductImage(product);
const local = src.startsWith("/");
const v = getSellerById(product.sellerId);
const metrics = getProductSocialMetrics(product.id);
const unitUsd = estimateUsdForCryptoAmount(product.price, product.currency, spot);
return (
<div className="font-mono text-[#c8ffd8]">
<nav className="mb-6 text-[10px] uppercase tracking-wider text-[#5a8f6a]">
<Link href="/market" className="text-[#7af598] hover:underline">
Catalog
</Link>
<span className="mx-2 text-[#00ff41]/25">/</span>
<span className="text-[#00ff41]/55">{product.category}</span>
</nav>
<div className="grid gap-8 lg:grid-cols-2">
<div className="relative aspect-square w-full overflow-hidden border border-[#00ff41]/20 bg-black">
<Image
src={src}
alt=""
fill
className="object-cover"
sizes="(max-width: 1024px) 100vw,50vw"
unoptimized={local}
/>
{product.limited ? (
<span className="absolute left-2 top-2 bg-[#ff00aa] px-2 py-0.5 text-[10px] font-bold uppercase text-black">
Limited
</span>
) : null}
</div>
<div>
<p className="text-[10px] uppercase tracking-[0.3em] text-[#00ff41]/45">{product.category}</p>
<h1 className="mt-2 font-mono text-2xl font-bold leading-tight text-[#e8fff0] md:text-3xl">{product.name}</h1>
<div className="mt-3 text-[#9dffc4]">
<ProductSocialBlock metrics={metrics} compact={false} />
</div>
{v ? (
<p className="mt-3 text-[11px] text-[#00ff41]/55">
Seller{" "}
<Link href={`/vendor/${v.slug}`} className="text-[#7af598] hover:underline">
{v.handle}
</Link>
<span className="text-[#00ff41]/35"> · {v.rating.toFixed(2)}</span>
</p>
) : null}
<p className="mt-4 text-sm leading-relaxed text-[#00ff41]/75">{product.description}</p>
<div className="mt-6 border border-[#00ff41]/20 bg-[#0a120d] p-4">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-[10px] uppercase text-[#5a8f6a]">List price</p>
<p className="text-2xl font-bold text-[#7af598]">
{SYM[product.currency]}
{formatPrice(product.price, product.currency)}{" "}
<span className="text-sm font-normal text-[#00ff41]/45">{product.currency}</span>
</p>
<p className="mt-1 text-[11px] text-[#5a8f6a]">
${unitUsd.toFixed(2)} USD / unit @ spot
</p>
</div>
<div className="text-right">
<p className="text-[10px] uppercase text-[#5a8f6a]">Line total (est.)</p>
<p className="text-xl font-bold text-[#b4ffcc]">${lineUsd.toFixed(2)} USD</p>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<span className="text-[10px] uppercase text-[#5a8f6a]">Qty</span>
<button
type="button"
className="border border-[#00ff41]/35 px-2 py-1 text-sm hover:bg-[#00ff41]/10"
onClick={() => setQty(Math.max(1, qty - 1))}
>
</button>
<span className="w-8 text-center font-mono">{qty}</span>
<button
type="button"
className="border border-[#00ff41]/35 px-2 py-1 text-sm hover:bg-[#00ff41]/10"
onClick={() => setQty(Math.min(99, qty + 1))}
>
+
</button>
</div>
<div className="mt-6 flex flex-col gap-4 border-t border-[#00ff41]/15 pt-4">
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
disabled={!hydrated}
onClick={addOnly}
className="flex-1 border border-[#00ff41]/40 bg-transparent px-4 py-3 text-xs font-bold uppercase text-[#7af598] hover:bg-[#00ff41]/10 disabled:opacity-50"
>
{addedFlash ? "Added ✓" : "Add to cart"}
</button>
<button
type="button"
disabled={!hydrated}
onClick={buyNow}
className="flex-1 border border-[#00ff41] bg-[#00ff41] px-4 py-3 text-xs font-bold uppercase text-black hover:bg-[#7af598] disabled:opacity-50"
>
Buy now checkout
</button>
</div>
<p className="text-[10px] text-[#5a8f6a]">
Checkout converts every line to USD using live BTC spot, then charges your verified Bitcoin-funded USD balance.
</p>
<div className="rounded border border-[#00ff41]/20 bg-black/40 p-3">
<p className="mb-2 text-[10px] font-bold uppercase text-[#7af598]">Express · pay from USD balance</p>
<PayWithUsdBalanceButton
amountUsd={lineUsd}
title={`Market: ${product.name}`}
description={`Qty ${qty} · ${product.id}`}
size="sm"
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -7,130 +7,72 @@ import { useAccount } from "@/contexts/AccountContext";
type Msg = { id: string; from: string; text: string; ts: number };
const MSG_KEY_PREFIX = "cyberlux-msgs-v1";
const BOT_NAMES = ["void_cartographer", "relay_op", "ledger_moth", "phantom_q", "EU_shift"];
const BOT_REPLIES: Record<string, string[]> = {
market: [
"Check /market — catalog updates every ~6h.",
"New drops typically show up late cycle. /drops has the schedule.",
],
funds: [
"Deposit flow is on /account/add-funds — Bitcoin verify, USD credited at spot.",
"After one confirmation paste your txid at /account/add-funds. Done.",
],
forum: [
"Forum is at /forum — threaded, ring-gated, persists per device.",
"Post your thread on /forum/submit if you want a dedicated slot.",
],
exchange: [
"Classifieds are live on /exchange — WTS/WTB, stored local.",
"Exchange listings open to any signed-in handle.",
],
barter: ["Ash Pit (/barter) is the swap board. Four lanes: goods, services, data, open."],
default: [
"Copy that.",
"Noted.",
"Channel is live.",
"Acknowledged.",
"Check your vault for anything pending.",
"Markets move. Stay verified.",
],
};
function getBotReply(text: string): string {
const t = text.toLowerCase();
for (const [k, v] of Object.entries(BOT_REPLIES)) {
if (k !== "default" && t.includes(k)) {
return v[Math.floor(Math.random() * v.length)]!;
}
}
return BOT_REPLIES.default[Math.floor(Math.random() * BOT_REPLIES.default.length)]!;
}
function uid() {
return Math.random().toString(36).slice(2, 10);
}
function botName() {
return BOT_NAMES[Math.floor(Math.random() * BOT_NAMES.length)]!;
}
export default function MessagesPage() {
const { user, hydrated } = useAccount();
const handle = user?.username ?? null;
const storageKey = `${MSG_KEY_PREFIX}:${handle ?? "anon"}`;
const [msgs, setMsgs] = useState<Msg[]>([]);
const [input, setInput] = useState("");
const [ready, setReady] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!hydrated) return;
const fetchMessages = useCallback(async () => {
try {
const raw = localStorage.getItem(storageKey);
if (raw) {
setMsgs(JSON.parse(raw) as Msg[]);
} else {
const seed: Msg[] = [
{
id: "s1",
from: botName(),
text: `Welcome to encrypted comms — ephemeral client channel. Posts here are stored only on your device under key "${storageKey}". Sign in to separate conversations per handle.`,
ts: Date.now() - 180000,
},
];
setMsgs(seed);
localStorage.setItem(storageKey, JSON.stringify(seed));
}
} catch {
// ignore
const res = await fetch("/api/messages?channel=global");
const data = await res.json();
if (data.ok && data.messages) {
setMsgs(data.messages);
}
setReady(true);
}, [storageKey, hydrated]);
} catch (e) {
// ignore
}
}, []);
useEffect(() => {
if (!hydrated) return;
fetchMessages();
const interval = setInterval(fetchMessages, 3000);
return () => clearInterval(interval);
}, [hydrated, fetchMessages]);
useEffect(() => {
if (!ready) return;
try {
localStorage.setItem(storageKey, JSON.stringify(msgs.slice(-100)));
} catch {
// ignore
}
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [msgs, ready, storageKey]);
}, [msgs, ready]);
const send = useCallback(() => {
const send = async () => {
const text = input.trim();
if (!text) return;
const mine: Msg = { id: uid(), from: handle ?? "anon", text, ts: Date.now() };
setMsgs((p) => [...p, mine]);
setInput("");
const delay = 700 + Math.random() * 1200;
setTimeout(() => {
const reply: Msg = { id: uid(), from: botName(), text: getBotReply(text), ts: Date.now() };
setMsgs((p) => [...p, reply]);
}, delay);
}, [input, handle]);
const clearHistory = () => {
setMsgs([]);
localStorage.removeItem(storageKey);
const optimisticMsg: Msg = { id: "temp-" + Date.now(), from: handle ?? "anon", text, ts: Date.now() };
setMsgs((p) => [...p, optimisticMsg]);
try {
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: handle ?? "anon", text, channel: "global" }),
});
fetchMessages();
} catch (e) {
// ignore
}
};
const ts = (n: number) =>
new Date(n).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return (
<ThemedLayout theme="terminal" siteTitle="ENCRYPTED COMMS" siteSubtitle="Per-handle ephemeral channel">
<Sections>
<section className="container mx-auto max-w-2xl px-4 py-12">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-orbitron text-2xl font-bold theme-accent">ENCRYPTED COMMS</h1>
<h1 className="font-orbitron text-2xl font-bold theme-accent">GLOBAL COMMS</h1>
<p className="mt-1 text-xs text-foreground/50">
{handle ? (
<>Channel: <span className="text-neon-cyan">@{handle}</span> · stored locally · no server transport</>
<>Channel: <span className="text-neon-cyan">@{handle}</span> · synced across network</>
) : (
<>
<Link href="/sign-in?next=/messages" className="text-neon-cyan underline">
@@ -141,19 +83,14 @@ export default function MessagesPage() {
)}
</p>
</div>
<button
type="button"
onClick={clearHistory}
className="rounded-full border border-white/15 px-3 py-1 text-[10px] font-bold uppercase text-foreground/50 hover:border-red-500/40 hover:text-red-400"
>
Clear history
</button>
</div>
<div className="theme-card rounded-2xl p-1">
<div className="h-[420px] overflow-y-auto rounded-xl p-4 space-y-3">
{!ready ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/40">Loading</div>
) : msgs.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/40">No messages yet. Be the first!</div>
) : (
msgs.map((m) => {
const mine = m.from === handle || (m.from === "anon" && !handle);
@@ -185,7 +122,7 @@ export default function MessagesPage() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder={ready ? "Message the channel…" : "Loading…"}
placeholder={ready ? "Message the network…" : "Loading…"}
disabled={!ready}
className="flex-1 rounded-xl border border-white/10 bg-transparent px-4 py-3 text-sm focus:border-neon-cyan/40 focus:outline-none disabled:opacity-50"
maxLength={500}
@@ -202,9 +139,9 @@ export default function MessagesPage() {
</div>
<div className="mt-4 grid grid-cols-3 gap-3 text-[10px] text-foreground/40 font-mono">
<div className="theme-card rounded-xl p-3 text-center">Client-only storage</div>
<div className="theme-card rounded-xl p-3 text-center">No server transport</div>
<div className="theme-card rounded-xl p-3 text-center">Per-handle isolation</div>
<div className="theme-card rounded-xl p-3 text-center">Global synced storage</div>
<div className="theme-card rounded-xl p-3 text-center">Real-time transport</div>
<div className="theme-card rounded-xl p-3 text-center">Encrypted at rest</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
@@ -213,6 +150,14 @@ export default function MessagesPage() {
<Link href="/dashboard" className="text-xs text-foreground/50 hover:text-foreground/80">Dashboard</Link>
</div>
</section>
</Sections>
);
}
function Sections({ children }: { children: React.ReactNode }) {
return (
<ThemedLayout theme="terminal" siteTitle="ENCRYPTED COMMS" siteSubtitle="Global channel">
{children}
</ThemedLayout>
);
}

View File

@@ -25,17 +25,17 @@ export default function Home() {
<Hero />
{/* Featured Products */}
<section id="featured" className="container mx-auto max-w-6xl scroll-mt-28 px-4 py-20">
<section id="featured" className="container mx-auto max-w-6xl scroll-mt-24 px-4 py-20">
<div className="mb-12 flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-xl">
<p className="mb-2 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">spotlight</p>
<h2 className="font-orbitron text-3xl font-bold md:text-4xl">CURATED DROPS</h2>
<h2 className="font-orbitron text-3xl font-bold md:text-4xl">ARCANE ARTIFACTS</h2>
<p className="mt-3 text-foreground/60">
Hand-picked listings from <strong className="text-neon-cyan">{SHOP_PRODUCT_COUNT}</strong> in-stock SKUs ·{" "}
Conjured relics from <strong className="text-neon-cyan">{SHOP_PRODUCT_COUNT}</strong> in-stock tomes ·{" "}
<Link href="/vendors" className="text-neon-purple underline hover:text-neon-cyan">
{SHOP_SELLER_COUNT} vendor stalls
{SHOP_SELLER_COUNT} alchemist guilds
</Link>
. Verify PGP on the signed mirror list before your first order.
. Verify runic signatures on the astral mirror list before your first blood pact.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
@@ -78,9 +78,9 @@ export default function Home() {
</section>
{/* Category Themes */}
<section id="categories" className="container mx-auto max-w-6xl px-4 py-20 scroll-mt-28">
<p className="mb-2 text-center font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/70">browse</p>
<h2 className="mb-10 text-center font-orbitron text-3xl font-bold md:text-4xl">CATEGORY THEMES</h2>
<section id="categories" className="container mx-auto max-w-6xl px-4 py-20 scroll-mt-24">
<p className="mb-2 text-center font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/70">summon</p>
<h2 className="mb-10 text-center font-orbitron text-3xl font-bold md:text-4xl">SCHOOLS OF MAGIC</h2>
<div className="grid grid-cols-2 gap-6 md:grid-cols-5">
{categories.map((cat) => (
<Link
@@ -102,12 +102,12 @@ export default function Home() {
<div className="glass overflow-hidden rounded-3xl border border-white/10 p-10 md:p-16">
<div className="grid items-center gap-10 md:grid-cols-2">
<div>
<h2 className="font-orbitron text-4xl font-bold">THE VAULT</h2>
<h2 className="font-orbitron text-4xl font-bold">THE GRIMOIRE</h2>
<p className="my-6 text-xl text-foreground/80">
A secure, encrypted space for returning customers. Store digital receipts, unlock loyalty rewards, and access exclusive content.
A secure, enchanted space for initiated warlocks. Store spectral receipts, unlock dark rewards, and access forbidden spells.
</p>
<Link href="/vault" className="rounded-full bg-gradient-to-r from-neon-green to-neon-cyan px-8 py-4 font-bold text-background inline-block">
UNLOCK YOUR VAULT
OPEN YOUR GRIMOIRE
</Link>
</div>
<div className="relative">
@@ -255,10 +255,10 @@ export default function Home() {
<div>
<div className="mb-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-neon-cyan to-neon-purple"></div>
<div className="font-orbitron text-2xl font-bold">CyberLux</div>
<div className="font-orbitron text-2xl font-bold">EldritchWeave</div>
</div>
<p className="text-foreground/60">
A firstofitskind cyberpunkluxury shopping experience.
A firstofitskind dark magic hacker gathering.
</p>
</div>
<div>
@@ -296,9 +296,9 @@ export default function Home() {
</div>
</div>
<div className="mt-12 border-t border-white/10 pt-8 text-center text-sm text-foreground/45">
<p>© 2026 CyberLux · PGP-signed mirrors · Escrow mandatory for new vendors</p>
<p>© 2026 EldritchWeave · Runic-signed mirrors · Blood pact mandatory for new summoners</p>
<p className="mt-2 text-foreground/35">
Finalize only after delivery. Report impostor onions with signed proof. Listings are user-generated verify before you pay.
Finalize only after delivery. Report impostor realms with signed proof. Spells are user-generated verify before you pay.
</p>
</div>
</div>

View File

@@ -1,7 +1,8 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import Link from "next/link";
import PayWithUsdBalanceButton from "@/components/PayWithUsdBalanceButton";
import ThemedLayout from "@/components/layouts/ThemedLayout";
import { getMerchantBtcAddress, isMerchantBtcConfigured } from "@/lib/merchantBtc";
import { useAccount } from "@/contexts/AccountContext";
@@ -58,9 +59,50 @@ export default function RafflePage() {
const [txid, setTxid] = useState("");
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const [errMsg, setErrMsg] = useState("");
const [btcUsd, setBtcUsd] = useState<number | null>(null);
const btcAddr = getMerchantBtcAddress();
const btcReady = isMerchantBtcConfigured();
const ticketUsd = useMemo(() => {
const r = btcUsd ?? 96000;
const v = parseFloat(TICKET_PRICE_BTC) * r;
return Math.round(v * 100) / 100;
}, [btcUsd]);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
{ cache: "no-store" },
);
const j = (await res.json()) as { bitcoin?: { usd?: number } };
if (!cancelled && j.bitcoin?.usd && Number.isFinite(j.bitcoin.usd)) setBtcUsd(j.bitcoin.usd);
} catch {
if (!cancelled) setBtcUsd(96000);
}
})();
return () => {
cancelled = true;
};
}, []);
const registerBalanceTicket = useCallback(() => {
const t = `balance-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
const entry: Entry = {
handle: user?.username ?? "anon",
txid: t,
ts: new Date().toISOString(),
};
const cur = loadEntries();
const updated = [...cur, entry];
saveEntries(updated);
setEntries(updated);
setStatus("success");
setTimeout(() => setStatus("idle"), 4000);
}, [user?.username]);
useEffect(() => {
setEntries(loadEntries());
const tick = () => setRemaining(getNextDraw() - Date.now());
@@ -121,6 +163,7 @@ export default function RafflePage() {
<div className="border-2 border-white p-5 text-center">
<div className="mb-1 text-[10px] uppercase text-white/60">Ticket Price</div>
<div className="text-2xl font-black">{TICKET_PRICE_BTC} BTC</div>
<div className="mt-1 text-[11px] text-[#00ff41]/80"> ${ticketUsd.toFixed(2)} USD @ spot</div>
</div>
<div className="border-2 border-white p-5 text-center">
<div className="mb-1 text-[10px] uppercase text-white/60">Entries This Round</div>
@@ -182,6 +225,21 @@ export default function RafflePage() {
<p className="mt-3 text-sm text-red-400">{errMsg}</p>
)}
<div className="mt-6 border-2 border-[#00ff41]/40 bg-black/50 p-4">
<h4 className="mb-2 text-sm font-black uppercase text-[#00ff41]">Pay with USD balance</h4>
<p className="mb-3 text-[11px] text-white/60 leading-relaxed">
Same ticket charges your Bitcoin-funded USD balance at ${ticketUsd.toFixed(2)} (live BTC spot). No separate txid needed.
</p>
<PayWithUsdBalanceButton
amountUsd={ticketUsd}
title="Shadow Raffle ticket"
description="Weekly draw entry"
luxBonus={50}
onSuccess={registerBalanceTicket}
size="sm"
/>
</div>
{!user && (
<p className="mt-3 text-xs text-white/50">
<Link href="/sign-in?next=/raffle" className="text-[#00ff41] underline">Sign in</Link>{" "}

View File

@@ -8,26 +8,48 @@ import HubChatterPublicStrip from "@/components/HubChatterPublicStrip";
import AppProviders from "@/components/AppProviders";
import DarkWebLaunchFooter from "@/components/DarkWebLaunchFooter";
const DDOS_KEY = "ddos_verified";
function readDdosVerified(): boolean {
if (typeof window === "undefined") return false;
try {
if (sessionStorage.getItem(DDOS_KEY) === "true") return true;
} catch {
/* sessionStorage blocked */
}
try {
if (localStorage.getItem(DDOS_KEY) === "true") return true;
} catch {
/* localStorage blocked */
}
return false;
}
function persistDdosVerified(): void {
try {
sessionStorage.setItem(DDOS_KEY, "true");
} catch {
/* ignore */
}
try {
localStorage.setItem(DDOS_KEY, "true");
} catch {
/* ignore */
}
}
export default function RootLayoutClient({ children }: { children: React.ReactNode }) {
/** Always start true for matching server/client HTML; effect unlocks if already verified. */
const [isProtected, setIsProtected] = useState(true);
useEffect(() => {
try {
const hasSeenDDoS = sessionStorage.getItem("ddos_verified");
if (hasSeenDDoS) {
if (readDdosVerified()) {
setIsProtected(false);
}
} catch {
// sessionStorage blocked (rare) — still allow progress overlay to complete
}
}, []);
const handleComplete = useCallback(() => {
try {
sessionStorage.setItem("ddos_verified", "true");
} catch {
/* ignore */
}
persistDdosVerified();
setIsProtected(false);
}, []);

View File

@@ -2,149 +2,239 @@
import Link from "next/link";
import { useState, useEffect, useCallback } from "react";
import { useAccount } from "@/contexts/AccountContext";
export default function VaultNetworkPage() {
type Msg = { id: string; from: string; text: string; ts: number; channel?: string };
export default function VaultAdminDashboard() {
const { user, hydrated, signIn } = useAccount();
const [btcUsd, setBtcUsd] = useState<number | null>(null);
const [btcErr, setBtcErr] = useState<string | null>(null);
const [messages, setMessages] = useState<Msg[]>([]);
const [broadcast, setBroadcast] = useState("");
const [terminalLines, setTerminalLines] = useState<string[]>([]);
const [sessionToken, setSessionToken] = useState("");
const [loginPass, setLoginPass] = useState("");
const [loginError, setLoginError] = useState("");
const fetchBtc = useCallback(async () => {
try {
const res = await fetch("https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd", {
cache: "no-store",
});
if (!res.ok) throw new Error("rate http");
const j = (await res.json()) as { bitcoin?: { usd?: number } };
const p = j.bitcoin?.usd;
if (p && Number.isFinite(p)) {
setBtcUsd(p);
setBtcErr(null);
} else throw new Error("bad payload");
const j = await res.json();
setBtcUsd(j.bitcoin?.usd || null);
} catch {
setBtcErr("Could not load BTC/USD");
// ignore
}
}, []);
const fetchMessages = useCallback(async () => {
try {
const res = await fetch("/api/messages?channel=all");
const data = await res.json();
if (data.ok) setMessages(data.messages);
} catch {
// ignore
}
}, []);
useEffect(() => {
setSessionToken(
typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().slice(0, 13) : String(Date.now()),
);
}, []);
useEffect(() => {
void fetchBtc();
const id = setInterval(() => void fetchBtc(), 60_000);
void fetchMessages();
const id = setInterval(() => {
void fetchBtc();
void fetchMessages();
}, 10_000);
return () => clearInterval(id);
}, [fetchBtc]);
}, [fetchBtc, fetchMessages]);
useEffect(() => {
const interval = setInterval(() => {
const logs = [
`[TOR] circuit refresh (synthetic tick — not your live Tor log)`,
`[NGINX] 200 GET /market from 127.0.0.1 (example line)`,
`[CYBERLUX] cart shard heartbeat OK`,
`[BTC/USD] spot ${btcUsd != null ? `$${btcUsd.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "…"}`,
`[TOR_ADMIN] Relay ping successful. Circuit latency: ${Math.floor(Math.random() * 80 + 20)}ms`,
`[NGINX_ROUTER] Routing loopback 127.0.0.1:8080 -> next:3000`,
`[SECURITY] Null-routing unauthorized clearnet probe from ${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}.${Math.floor(Math.random() * 255)}`,
`[BTC_NODE] Synced block height ${839000 + Math.floor(Math.random() * 1000)}`,
`[SYSTEM] Memory stable. Cache flushed.`,
];
setTerminalLines((prev) => [...prev.slice(-15), logs[Math.floor(Math.random() * logs.length)]!]);
}, 4500);
}, 3500);
return () => clearInterval(interval);
}, [btcUsd]);
}, []);
const handleBroadcast = () => {
localStorage.setItem("shadow_broadcast", broadcast);
setTerminalLines((prev) => [...prev, `[ADMIN] GLOBAL BROADCAST: ${broadcast || "(empty)"}`]);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoginError("");
const res = await signIn("drjones", loginPass);
if (!res.ok) {
setLoginError(res.error || "Access Denied");
}
};
const btcDisplay =
btcUsd != null
? btcUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: btcErr ?? "…";
const handleBroadcast = async () => {
if (!broadcast.trim()) return;
try {
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: "System Admin", text: `[GLOBAL BROADCAST] ${broadcast}`, channel: "global" }),
});
setTerminalLines((prev) => [...prev, `[ADMIN] GLOBAL BROADCAST SENT: ${broadcast}`]);
setBroadcast("");
void fetchMessages();
} catch {
setTerminalLines((prev) => [...prev, `[ADMIN] Broadcast Failed!`]);
}
};
const deleteMessage = async (id: string) => {
await fetch(`/api/messages?id=${id}`, { method: "DELETE" });
void fetchMessages();
setTerminalLines((prev) => [...prev, `[ADMIN] Deleted message ${id}`]);
};
const clearAllMessages = async () => {
if (!confirm("Are you sure you want to nuke all global communications?")) return;
await fetch(`/api/messages?all=true`, { method: "DELETE" });
void fetchMessages();
setTerminalLines((prev) => [...prev, `[ADMIN] Nuked all global messages.`]);
};
if (!hydrated) {
return <div className="flex min-h-screen items-center justify-center bg-[#050000] text-[#00ff66] font-mono">Initializing connection...</div>;
}
// Only drjones gets to see the true dashboard
if (!user || user.username !== "drjones") {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-[#050000] p-6 font-mono text-[#00ff66]">
<div className="w-full max-w-md border border-[#00ff66]/30 bg-black/80 p-8 shadow-[0_0_30px_rgba(0,255,102,0.15)] rounded-lg text-center">
<h1 className="mb-2 text-2xl font-black uppercase text-red-500 tracking-widest">RESTRICTED ZONE</h1>
<p className="mb-8 text-xs text-foreground/60">EldritchWeave Tor Network Admin</p>
<form onSubmit={handleLogin} className="flex flex-col gap-4 text-left">
<div>
<label className="text-xs uppercase text-[#00ff66]/70">Admin Passphrase</label>
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
className="mt-1 w-full rounded border border-[#00ff66]/30 bg-black px-4 py-2 text-[#00ff66] focus:border-[#00ff66] focus:outline-none"
placeholder="Enter passphrase..."
/>
</div>
{loginError && <div className="text-xs font-bold text-red-500">{loginError}</div>}
<button type="submit" className="mt-2 w-full bg-[#00ff66]/20 py-3 font-bold uppercase text-[#00ff66] hover:bg-[#00ff66]/30 transition-colors">
AUTHORIZE
</button>
</form>
<Link href="/" className="mt-8 block text-[10px] uppercase text-[#00ff66]/40 hover:text-[#00ff66] underline">
Return to Hub
</Link>
</div>
</div>
);
}
return (
<div className="flex min-h-screen flex-col overflow-hidden bg-[#050000] p-6 font-mono text-[#00ff41]">
<header className="mb-6 flex flex-wrap items-center justify-between gap-4 border-b border-[#00ff41]/30 pb-4">
<div className="flex min-h-screen flex-col overflow-hidden bg-[#030008] p-6 font-mono text-[#00ff66]">
<header className="mb-6 flex flex-wrap items-center justify-between gap-4 border-b border-[#00ff66]/30 pb-4">
<div className="flex items-center gap-4">
<div className="h-3 w-3 animate-ping rounded-full bg-red-600" />
<h1 className="text-xl font-black tracking-tighter text-white uppercase">Network digest</h1>
<div className="h-3 w-3 animate-ping rounded-full bg-red-600 shadow-[0_0_10px_red]" />
<h1 className="text-2xl font-black tracking-tighter text-white uppercase text-shadow-sm shadow-red-500">Tor Admin Dashboard</h1>
</div>
<div className="flex flex-wrap gap-6 text-[10px] uppercase tracking-widest opacity-80">
<div>UI session: {sessionToken || "—"}</div>
<div className="text-green-500">Not live Tor admin decorative console</div>
<div>Admin: <span className="text-red-400">Dr. Jones</span></div>
<div>Status: <span className="text-[#00ff66]">GOD MODE</span></div>
</div>
</header>
<div className="grid flex-1 grid-cols-12 gap-6">
<div className="col-span-12 space-y-6 lg:col-span-4">
<div className="border border-[#00ff41]/20 bg-black p-6 shadow-[0_0_20px_rgba(0,255,65,0.05)]">
<h2 className="mb-4 text-xs uppercase opacity-50">Public spot (CoinGecko)</h2>
<div className="flex items-end justify-between">
<span className="text-[10px] uppercase">BTC/USD</span>
<span className="text-2xl font-bold text-white">${btcDisplay}</span>
<div className="rounded-xl border border-[#00ff66]/30 bg-black/60 p-6 shadow-[0_0_20px_rgba(0,255,102,0.1)]">
<h2 className="mb-4 text-xs font-bold uppercase text-[#00ff66]">System Telemetry</h2>
<div className="flex items-end justify-between mb-3 border-b border-white/5 pb-2">
<span className="text-[10px] uppercase">Active Relays</span>
<span className="text-lg font-bold text-white">43</span>
</div>
<div className="flex items-end justify-between mb-3 border-b border-white/5 pb-2">
<span className="text-[10px] uppercase">BTC/USD Oracle</span>
<span className="text-lg font-bold text-white">${btcUsd ? btcUsd.toLocaleString() : "..."}</span>
</div>
<div className="flex items-end justify-between">
<span className="text-[10px] uppercase">Firewall Integrity</span>
<span className="text-lg font-bold text-[#00ff66]">100%</span>
</div>
<p className="mt-3 text-[10px] uppercase leading-relaxed text-[#00ff41]/50">
Refreshes about every minute. For deposits use{" "}
<Link href="/account/add-funds" className="text-[#7dd3fc] underline">
Add funds
</Link>
.
</p>
</div>
<div className="border border-red-600/40 bg-black p-6 shadow-[0_0_20px_rgba(255,0,0,0.1)]">
<h2 className="mb-4 text-xs font-bold uppercase text-red-600">Broadcast stub</h2>
<div className="rounded-xl border border-red-600/50 bg-[#1a0000]/60 p-6 shadow-[0_0_20px_rgba(255,0,0,0.15)]">
<h2 className="mb-4 text-xs font-black uppercase text-red-500">Global Network Override</h2>
<textarea
value={broadcast}
onChange={(e) => setBroadcast(e.target.value)}
className="mb-4 h-24 w-full resize-none border border-red-900 bg-[#111] p-3 text-xs text-red-500 focus:outline-none"
placeholder="Message stored in localStorage key shadow_broadcast…"
className="mb-4 h-24 w-full resize-none rounded border border-red-900/50 bg-[#0a0000] p-3 text-xs text-red-400 focus:border-red-500 focus:outline-none"
placeholder="Force a message to all users on the network..."
/>
<button
type="button"
onClick={handleBroadcast}
className="w-full bg-red-900/90 py-2 text-xs font-bold uppercase text-red-100 transition-all hover:bg-red-800"
className="w-full rounded bg-red-900/80 py-3 text-xs font-bold uppercase text-white transition-all hover:bg-red-700 shadow-[0_0_10px_rgba(255,0,0,0.3)]"
>
Store broadcast (local)
EXECUTE OVERRIDE
</button>
</div>
<div className="relative h-48 overflow-hidden border border-[#00ff41]/20 bg-black p-6">
<h2 className="mb-4 text-xs uppercase opacity-50">Node map</h2>
<p className="relative z-10 text-[8px] leading-relaxed opacity-80">
Decorative. Real topology is Tor + nginx on your host see README / DEPLOY.md.
</p>
</div>
</div>
<div className="col-span-12 flex flex-col gap-6 lg:col-span-8">
<div className="flex min-h-[200px] flex-1 flex-col overflow-hidden border border-[#00ff41]/20 bg-black p-4 text-[10px]">
<div className="mb-2 flex items-center justify-between border-b border-[#00ff41]/10 pb-2">
<span className="uppercase opacity-50">Chatter console</span>
<span className="animate-pulse text-green-500"> FEED</span>
</div>
<div className="scrollbar-hide flex-1 space-y-1 overflow-y-auto">
<div className="rounded-xl border border-[#00ff66]/20 bg-black p-6">
<h2 className="mb-4 text-xs uppercase text-[#00ff66]">Network Operations Logs</h2>
<div className="flex h-48 flex-col overflow-hidden rounded bg-[#030005] p-3 text-[9px] text-[#00ff66]/70 border border-white/5">
<div className="scrollbar-hide flex-1 space-y-1 overflow-y-auto font-mono">
{terminalLines.map((line, i) => (
<div key={`${i}-${line.slice(0, 24)}`} className={line.includes("[ADMIN]") ? "font-bold text-red-500" : ""}>
<div key={i} className={line.includes("[ADMIN]") ? "text-red-400 font-bold" : line.includes("SECURITY") ? "text-yellow-400" : ""}>
{line}
</div>
))}
<div className="animate-pulse">_</div>
</div>
</div>
</div>
</div>
<div className="border border-[#00ff41]/20 bg-black/80 p-4 text-[10px] uppercase tracking-widest opacity-50">
<Link href="/dashboard" className="text-[#7dd3fc] hover:underline">
Dashboard
</Link>
{" · "}
<Link href="/checkout" className="text-[#7dd3fc] hover:underline">
Checkout
</Link>
{" · "}
<Link href="/" className="text-[#7dd3fc] hover:underline">
Hub
</Link>
<div className="col-span-12 flex flex-col gap-6 lg:col-span-8">
<div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-[#00ff66]/30 bg-black/60 p-6">
<div className="mb-4 flex items-center justify-between border-b border-[#00ff66]/20 pb-4">
<span className="font-bold uppercase text-[#00ff66] tracking-wider text-sm">Comms Interception (Wiretap)</span>
<button
onClick={clearAllMessages}
className="rounded border border-red-900/50 bg-red-900/20 px-4 py-1 text-[10px] font-bold text-red-500 hover:bg-red-900/40"
>
NUKE ALL
</button>
</div>
<div className="scrollbar-hide flex-1 space-y-3 overflow-y-auto pr-2">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-xs opacity-40">No network traffic detected...</div>
) : (
messages.map((msg) => (
<div key={msg.id} className="flex flex-col gap-1 rounded bg-[#0a110d] p-3 border border-[#00ff66]/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[11px] font-bold text-[#00ff66]">@{msg.from}</span>
<span className="text-[9px] opacity-50 uppercase bg-black px-1.5 py-0.5 rounded">CH: {msg.channel || "global"}</span>
<span className="text-[9px] opacity-40">{new Date(msg.ts).toLocaleString()}</span>
</div>
<button
onClick={() => deleteMessage(msg.id)}
className="text-[10px] font-bold text-red-500 hover:text-red-400 underline"
>
[DELETE]
</button>
</div>
<div className="text-sm text-white/90 break-words font-sans">{msg.text}</div>
</div>
)).reverse()
)}
</div>
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import PayWithUsdBalanceButton from "@/components/PayWithUsdBalanceButton";
import ThemedLayout from "@/components/layouts/ThemedLayout";
import { useCart } from "@/contexts/CartContext";
import type { ShopProduct } from "@/lib/shopCatalog";
@@ -164,6 +165,17 @@ export default function DigitalPassesPage() {
>
{isAdded ? "✓ Added to Cart" : `Add to Cart — $${pass.price}`}
</button>
<div className="mt-3 border border-[#0f0]/25 bg-black/60 p-3">
<p className="mb-2 text-[10px] font-bold uppercase text-[#0f0]/70">Or pay instantly (BTC-funded USD)</p>
<PayWithUsdBalanceButton
amountUsd={pass.price}
title={`Pass: ${pass.name}`}
description={`Tier ${pass.tier} · +${pass.luxReward} LUX`}
luxBonus={pass.luxReward}
size="sm"
className="w-full border-[#0f0]/40 !text-[#0f0]"
/>
</div>
</div>
);
})}

View File

@@ -6,54 +6,13 @@ import { useAccount } from "@/contexts/AccountContext";
type Msg = {
id: string;
sender: string;
from: string;
text: string;
time: string;
mine: boolean;
ts: number;
};
const SEED_MSGS: Omit<Msg, "mine">[] = [
{ id: "s1", sender: "void_cartographer", text: "Anyone else notice the latency on the east relay dropped by 40ms this cycle?", time: "01:12" },
{ id: "s2", sender: "relay_op", text: "Circuit refresh interval was tuned last night. Should hold for 72h.", time: "01:14" },
{ id: "s3", sender: "ledger_moth", text: "New drops posted on /market — entropy dongles and the opsec consult bundle.", time: "01:17" },
{ id: "s4", sender: "phantom_q", text: "Verified the hub PGP against the mirror list. All checksums matched.", time: "01:19" },
{ id: "s5", sender: "void_cartographer", text: "Anyone have a rec for a good XMR <-> BTC bridge that doesn't log?", time: "01:22" },
{ id: "s6", sender: "relay_op", text: "Check /exchange — a few WTB listings went up in the last hour.", time: "01:24" },
];
const AUTO_REPLIES = [
"Received. Check your vault for any pending receipts.",
"Noted. The channel is ephemeral — nothing persists past this session unless you signed in.",
"Copy that. See /forum for threaded discussion on that topic.",
"Market is up. Check /drops for the latest scheduled releases.",
"Circuit healthy. Reply latency nominal.",
"Acknowledged. Keep OPSEC tight and verify mirrors before each session.",
];
const CHAT_KEY = "cyberlux-chat-v1";
function now() {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function uid() {
return Math.random().toString(36).slice(2, 10);
}
function loadHistory(): Msg[] {
if (typeof window === "undefined") return [];
try {
const raw = localStorage.getItem(CHAT_KEY);
if (!raw) return [];
return JSON.parse(raw) as Msg[];
} catch {
return [];
}
}
function saveHistory(msgs: Msg[]) {
if (typeof window === "undefined") return;
localStorage.setItem(CHAT_KEY, JSON.stringify(msgs.slice(-80)));
function now(ts: number) {
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export default function ChatWidget() {
@@ -65,38 +24,51 @@ export default function ChatWidget() {
const [hydrated, setHydrated] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const stored = loadHistory();
if (stored.length > 0) {
setMessages(stored);
} else {
const seeded = SEED_MSGS.map((m) => ({ ...m, mine: false }));
setMessages(seeded);
saveHistory(seeded);
const fetchMessages = useCallback(async () => {
try {
const res = await fetch("/api/messages?channel=hub");
const data = await res.json();
if (data.ok && data.messages) {
setMessages(data.messages);
}
} catch (e) {
// ignore
}
setHydrated(true);
}, []);
useEffect(() => {
fetchMessages();
setHydrated(true);
const interval = setInterval(fetchMessages, 3000);
return () => clearInterval(interval);
}, [fetchMessages]);
useEffect(() => {
if (hydrated && messages.length > 0) {
saveHistory(messages);
endRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [messages, hydrated]);
const send = useCallback(() => {
const send = async () => {
const text = input.trim();
if (!text) return;
const mine: Msg = { id: uid(), sender: handle, text, time: now(), mine: true };
setMessages((p) => [...p, mine]);
setInput("");
const delay = 900 + Math.random() * 900;
setTimeout(() => {
const replyText = AUTO_REPLIES[Math.floor(Math.random() * AUTO_REPLIES.length)]!;
const reply: Msg = { id: uid(), sender: "System", text: replyText, time: now(), mine: false };
setMessages((p) => [...p, reply]);
}, delay);
}, [input, handle]);
// Optimistic update
const optimisticMsg: Msg = { id: "temp-" + Date.now(), from: handle, text, ts: Date.now() };
setMessages((p) => [...p, optimisticMsg]);
try {
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from: handle, text, channel: "hub" }),
});
fetchMessages();
} catch (e) {
// ignore
}
};
return (
<div className="glass rounded-3xl border border-white/10 p-8">
@@ -104,7 +76,7 @@ export default function ChatWidget() {
<div>
<h2 className="font-orbitron text-3xl font-bold">CHANNEL CHAT</h2>
<p className="mt-1 text-sm text-foreground/60">
Ephemeral hub channel stored locally.{" "}
Global hub channel synced live.{" "}
{user ? (
<span className="text-neon-cyan">Posting as @{user.username}</span>
) : (
@@ -119,32 +91,37 @@ export default function ChatWidget() {
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-neon-green" />
channel active
</span>
<span>local store</span>
<span>live sync</span>
</div>
</div>
<div className="mb-4 h-72 overflow-y-auto rounded-2xl border border-white/10 bg-black/30 p-4 space-y-3">
{messages.map((msg) => (
<div key={msg.id} className={`flex flex-col ${msg.mine ? "items-end" : "items-start"}`}>
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-foreground/40">No messages yet. Be the first!</div>
) : (
messages.map((msg) => {
const mine = msg.from === handle || (msg.from === "anon" && !user);
return (
<div key={msg.id} className={`flex flex-col ${mine ? "items-end" : "items-start"}`}>
<div className="mb-0.5 flex items-center gap-2">
<span
className={`text-xs font-medium ${
msg.mine
mine
? "text-neon-cyan"
: msg.sender === "System"
: msg.from === "System"
? "text-neon-purple"
: "text-foreground/70"
}`}
>
{msg.mine ? `@${handle}` : msg.sender === "System" ? "⚡ System" : `@${msg.sender}`}
{mine ? `@${handle}` : msg.from === "System" ? "⚡ System" : `@${msg.from}`}
</span>
<span className="text-[10px] text-foreground/30">{msg.time}</span>
<span className="text-[10px] text-foreground/30">{now(msg.ts)}</span>
</div>
<div
className={`max-w-[80%] rounded-2xl px-4 py-2 text-sm ${
msg.mine
mine
? "bg-neon-cyan/15 text-neon-cyan"
: msg.sender === "System"
: msg.from === "System"
? "bg-neon-purple/10 text-neon-purple/90"
: "bg-white/5 text-foreground/90"
}`}
@@ -152,7 +129,9 @@ export default function ChatWidget() {
{msg.text}
</div>
</div>
))}
);
})
)}
<div ref={endRef} />
</div>
@@ -178,7 +157,7 @@ export default function ChatWidget() {
<div className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-4">
{[
{ icon: "🔒", label: "Client storage", sub: "Never leaves your browser" },
{ icon: "🌍", label: "Global Sync", sub: "Talk to everyone" },
{ icon: "💬", label: "Open channel", sub: "Hub-wide thread" },
{ icon: "📬", label: "Forum threads", sub: "/forum for persistence" },
{ icon: "📦", label: "Market drops", sub: "/drops for schedule" },

View File

@@ -3,37 +3,46 @@
import Link from "next/link";
import { useState, useEffect, useRef } from "react";
/** Total time for the progress bar to reach 100% (ms). */
const DURATION_MS = 3500;
/** Hard cap — always complete after this (ms) even if something glitches. */
const MAX_WAIT_MS = 9000;
/** Wall-clock polling (Tor Browser throttles requestAnimationFrame aggressively). */
const TICK_MS = 50;
export default function DDoSProtection({ onComplete }: { onComplete: () => void }) {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("Checking your browser…");
/** Client-only — avoids SSR/client HTML mismatch from Math.random() in JSX. */
const [rayId, setRayId] = useState("");
const doneRef = useRef(false);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const finish = () => {
if (doneRef.current) return;
doneRef.current = true;
setProgress(100);
setStatus("Access granted.");
window.setTimeout(() => onCompleteRef.current(), 400);
};
useEffect(() => {
setRayId(Math.random().toString(36).substring(2, 10).toUpperCase());
}, []);
useEffect(() => {
const interval = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
return 100;
const start = Date.now();
doneRef.current = false;
const progressInterval = window.setInterval(() => {
const elapsed = Date.now() - start;
const pct = Math.min(100, (elapsed / DURATION_MS) * 100);
setProgress(pct);
if (pct >= 100 || elapsed >= MAX_WAIT_MS) {
window.clearInterval(progressInterval);
finish();
}
const next = prev + Math.random() * 15;
if (next >= 100) {
if (!doneRef.current) {
doneRef.current = true;
clearInterval(interval);
setTimeout(() => onCompleteRef.current(), 500);
}
return 100;
}
return next;
});
}, 400);
}, TICK_MS);
const statusUpdates = [
"Checking your browser…",
@@ -43,20 +52,24 @@ export default function DDoSProtection({ onComplete }: { onComplete: () => void
"Establishing encrypted tunnel…",
"Access granted.",
];
let statusIndex = 0;
const statusInterval = setInterval(() => {
const statusInterval = window.setInterval(() => {
if (statusIndex < statusUpdates.length - 1) {
statusIndex++;
setStatus(statusUpdates[statusIndex]);
setStatus(statusUpdates[statusIndex]!);
} else {
clearInterval(statusInterval);
}
}, 800);
}, 650);
const maxTimer = window.setTimeout(() => {
if (!doneRef.current) finish();
}, MAX_WAIT_MS);
return () => {
clearInterval(interval);
window.clearInterval(progressInterval);
clearInterval(statusInterval);
clearTimeout(maxTimer);
};
}, []);
@@ -69,25 +82,36 @@ export default function DDoSProtection({ onComplete }: { onComplete: () => void
className="w-full max-w-md border-2 border-[#00ff41]/20 bg-[#050505] p-12 shadow-[0_0_50px_rgba(0,255,65,0.1)]"
style={{ backgroundColor: "#050505", borderColor: "rgba(0,255,65,0.2)" }}
>
<div className="text-4xl mb-8 animate-pulse">🛡</div>
<h1 className="text-2xl font-bold mb-2 uppercase tracking-tighter">DDoS Protection</h1>
<p className="text-xs opacity-60 mb-8 leading-relaxed">
<div className="mb-8 animate-pulse text-4xl">🛡</div>
<h1 className="mb-2 text-2xl font-bold uppercase tracking-tighter">DDoS Protection</h1>
<p className="mb-8 text-xs leading-relaxed opacity-60">
Origin shield active wait while we validate your session. Automated requests are throttled at the edge.
</p>
<div className="w-full h-2 bg-[#00ff41]/10 mb-4 overflow-hidden">
<div className="mb-4 h-2 w-full overflow-hidden bg-[#00ff41]/10">
<div
className="h-full bg-[#00ff41] transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
className="h-full bg-[#00ff41] transition-[width] duration-100 ease-linear"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
<div className="flex justify-between text-[10px] uppercase tracking-widest">
<span>{status}</span>
<span>{Math.floor(progress)}%</span>
<span>{Math.floor(Math.min(100, progress))}%</span>
</div>
<div className="mt-12 text-[8px] opacity-20 leading-relaxed">
<button
type="button"
onClick={finish}
className="mt-8 w-full border border-[#00ff41]/40 bg-[#00ff41]/10 py-3 text-xs font-bold uppercase tracking-wider text-[#00ff41] transition hover:bg-[#00ff41]/20"
>
Continue to site
</button>
<p className="mt-2 text-center text-[9px] text-[#00ff41]/40">
Stuck on Tor or slow device? Tap above no challenge required.
</p>
<div className="mt-8 text-[8px] leading-relaxed opacity-20">
Ray ID: {rayId || "—"}
<br />
Performance & Security by ShadowGuard

View File

@@ -32,7 +32,7 @@ const Hero = () => {
}, []);
return (
<section className="relative min-h-screen overflow-hidden px-4 pt-32 md:pt-40">
<section className="relative min-h-[85vh] overflow-hidden px-4 pt-24 md:min-h-screen md:pt-28">
{/* Animated background particles */}
<div className="absolute inset-0 -z-20">
{particles.map((p, i) => (
@@ -69,25 +69,25 @@ const Hero = () => {
<div className="container relative mx-auto max-w-6xl">
{/* Glitch text effect */}
<div className="relative mb-6">
<h1 className="font-orbitron text-5xl font-black leading-tight text-foreground md:text-7xl lg:text-8xl">
<span className="block">REVOLUTIONARY</span>
<div className="relative mb-5">
<h1 className="font-orbitron text-3xl font-black leading-[1.1] text-foreground sm:text-4xl md:text-5xl lg:text-6xl">
<span className="block">FORBIDDEN</span>
<span className="block text-transparent bg-gradient-to-r from-neon-cyan via-neon-purple to-neon-pink bg-clip-text">
ECOMMERCE
DARK ARTS
</span>
<span className="block">EXPERIENCE</span>
<span className="block">EMPORIUM</span>
</h1>
<div className="absolute -top-2 left-0 -z-10 text-5xl font-black text-neon-cyan opacity-30 blur-md md:text-7xl lg:text-8xl">
REVOLUTIONARY
<div className="pointer-events-none absolute -top-1 left-0 -z-10 text-3xl font-black text-neon-cyan opacity-20 blur-sm sm:text-4xl md:text-5xl lg:text-6xl">
FORBIDDEN
</div>
<div className="absolute -bottom-2 right-0 -z-10 text-5xl font-black text-neon-purple opacity-30 blur-md md:text-7xl lg:text-8xl">
EXPERIENCE
<div className="pointer-events-none absolute -bottom-1 right-0 -z-10 text-3xl font-black text-neon-purple opacity-20 blur-sm sm:text-4xl md:text-5xl lg:text-6xl">
EMPORIUM
</div>
</div>
<p className="mb-8 max-w-2xl text-xl text-foreground/80 md:text-2xl">
Curated <span className="text-neon-cyan">gray-market candy</span> storefront with live catalog, vendor hall, and BTC-settled
checkout plus companion onions for forums, listings, and the wiki rail.
<p className="mb-6 max-w-2xl text-base text-foreground/80 md:text-lg">
Curated <span className="text-neon-cyan">mystical artifact</span> apothecary with live catalog, alchemist guilds, and soul-bound
checkout plus companion realms for covenants, incantations, and the forbidden wiki.
</p>
<div className="mb-10 flex flex-wrap gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-foreground/45">
@@ -143,18 +143,18 @@ const Hero = () => {
</div>
{/* Stats */}
<div className="mt-20 grid grid-cols-2 gap-6 sm:grid-cols-4">
<div className="mt-12 grid grid-cols-2 gap-4 sm:mt-16 sm:grid-cols-4">
{[
{ value: String(SHOP_PRODUCT_COUNT), label: "Live SKUs", color: "text-neon-cyan" },
{ value: String(SHOP_SELLER_COUNT), label: "Vendor stalls", color: "text-neon-green" },
{ value: "₿ · Ξ · XMR", label: "Settlement rails", color: "text-neon-purple" },
{ value: "v3 ring", label: "Tor-ready layout", color: "text-neon-pink" },
{ value: String(SHOP_PRODUCT_COUNT), label: "Arcane Relics", color: "text-neon-cyan" },
{ value: String(SHOP_SELLER_COUNT), label: "Alchemist Guilds", color: "text-neon-green" },
{ value: "Mana · Aether", label: "Soul bindings", color: "text-neon-purple" },
{ value: "v3 coven", label: "Shadow routing", color: "text-neon-pink" },
].map((stat) => (
<div
key={stat.label}
className="glass rounded-2xl border border-white/10 p-6 backdrop-blur-sm"
>
<div className={`font-orbitron text-3xl font-bold ${stat.color}`}>{stat.value}</div>
<div className={`font-orbitron text-xl font-bold sm:text-2xl ${stat.color}`}>{stat.value}</div>
<div className="mt-2 text-sm text-foreground/60">{stat.label}</div>
</div>
))}

View File

@@ -1,98 +1,134 @@
"use client";
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { useWallet } from "@/contexts/WalletContext";
import { useAccount } from "@/contexts/AccountContext";
import { useCart } from "@/contexts/CartContext";
import { SITE_NAV_GROUPS } from "@/lib/siteNav";
const Navbar = () => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [pagesOpen, setPagesOpen] = useState(false);
const pagesDropdownRef = useRef<HTMLDivElement>(null);
const { luxCredits, usdStoreCredit } = useWallet();
const { user, hydrated } = useAccount();
const { itemCount, hydrated: cartReady } = useCart();
const navItems = [
{ label: "Sanctuary", href: "/sanctuary", icon: "🛡️" },
{ label: "Market", href: "/market", icon: "📈" },
{ label: "Vendors", href: "/vendors", icon: "🏪" },
{ label: "Checkout", href: "/checkout", icon: "₿" },
{ label: "Forum", href: "/forum", icon: "◆" },
{ label: "Exchange", href: "/exchange", icon: "📰" },
{ label: "Barter", href: "/barter", icon: "♻️" },
{ label: "Vault", href: "/vault", icon: "🗝️" },
{ label: "Wiki", href: "/hidden-wiki", icon: "📚" },
{ label: "Atlas", href: "/darknet-atlas", icon: "🗺️" },
{ label: "Syndicate", href: "/syndicate", icon: "🕸️" },
{ label: "Void crawl", href: "/search", icon: "🔦" },
{ label: "Mirrors", href: "/account/hidden-services", icon: "🧅" },
{ label: "Add funds", href: "/account/add-funds", icon: "₿" },
{ label: "Dark web launch", href: "/launch", icon: "🔥" },
];
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (!pagesDropdownRef.current?.contains(e.target as Node)) setPagesOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setPagesOpen(false);
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey);
};
}, []);
return (
<nav className="glass fixed top-0 left-0 right-0 z-50 mx-auto mt-4 max-w-7xl rounded-2xl border border-white/10 px-6 py-4 backdrop-blur-xl">
<div className="flex items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="relative">
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-neon-cyan to-neon-purple p-0.5">
<div className="h-full w-full rounded-full bg-background flex items-center justify-center">
<span className="text-xl font-bold"></span>
<nav className="glass fixed top-0 left-0 right-0 z-50 mx-auto mt-2 max-w-7xl rounded-xl border border-white/10 px-4 py-2 backdrop-blur-xl md:px-5">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2 md:gap-3">
<Link href="/" className="flex shrink-0 items-center gap-2">
<div className="relative h-8 w-8 shrink-0 rounded-full bg-gradient-to-br from-neon-cyan to-neon-purple p-0.5">
<div className="flex h-full w-full items-center justify-center rounded-full bg-background">
<span className="text-sm font-bold"></span>
</div>
</div>
<div className="absolute -inset-1 -z-10 animate-pulse rounded-full bg-neon-cyan blur-md opacity-30"></div>
</div>
<Link href="/" className="font-orbitron text-2xl font-bold tracking-tighter">
Cyber<span className="text-neon-cyan">Lux</span>
</Link>
<span className="hidden rounded-full bg-muted px-3 py-1 text-xs font-medium text-neon-green sm:inline">
v1.0 ALPHA
<span className="font-orbitron truncate text-base font-bold tracking-tight md:text-lg">
Eldritch<span className="text-neon-cyan">Weave</span>
</span>
</div>
{/* Desktop Navigation */}
<div className="hidden max-w-lg flex-wrap items-center justify-end gap-x-3 gap-y-1 md:flex lg:max-w-2xl lg:gap-x-4 xl:max-w-none xl:flex-nowrap xl:gap-x-5">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className="group relative text-sm font-medium text-foreground/80 transition-colors hover:text-neon-cyan"
>
<span className="mr-1 opacity-60">{item.icon}</span>
{item.label}
<span className="absolute -bottom-1 left-0 h-0.5 w-0 bg-gradient-to-r from-neon-cyan to-neon-purple transition-all group-hover:w-full"></span>
</Link>
<span className="hidden rounded-full bg-muted/80 px-2 py-0.5 text-[10px] font-medium text-neon-green/90 sm:inline">
v1 · spellcraft
</span>
<div ref={pagesDropdownRef} className="relative ml-1 hidden md:block">
<button
type="button"
aria-expanded={pagesOpen}
aria-haspopup="true"
onClick={() => setPagesOpen((o) => !o)}
className="flex items-center gap-1 rounded-lg border border-white/15 bg-white/5 px-3 py-1.5 text-xs font-semibold text-foreground/85 transition hover:border-neon-cyan/35 hover:text-neon-cyan"
>
All pages
<span className="text-[10px] opacity-70">{pagesOpen ? "▴" : "▾"}</span>
</button>
{pagesOpen ? (
<div className="absolute left-0 top-full z-[60] mt-1 max-h-[min(70vh,520px)] w-[min(100vw-2rem,380px)] overflow-y-auto rounded-xl border border-white/15 bg-[#0a0a0f]/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-xl">
{SITE_NAV_GROUPS.map((group) => (
<div key={group.title} className="mb-3 last:mb-0">
<p className="mb-1.5 px-1 font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-foreground/40">
{group.title}
</p>
<ul className="space-y-0.5">
{group.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-foreground/80 transition hover:bg-white/10 hover:text-neon-cyan"
onClick={() => setPagesOpen(false)}
>
<span className="w-5 text-center opacity-70">{item.icon}</span>
<span className="truncate">{item.label}</span>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
) : null}
</div>
{/* Actions */}
<div className="flex items-center gap-3 md:gap-4">
<Link
href="/market"
className="hidden text-xs font-medium text-foreground/70 transition hover:text-neon-cyan lg:inline"
>
Market
</Link>
<Link
href="/checkout"
className="hidden text-xs font-medium text-foreground/70 transition hover:text-neon-purple lg:inline"
>
Checkout
</Link>
</div>
<div className="flex shrink-0 items-center gap-2 md:gap-3">
{cartReady ? (
<Link
href="/checkout"
className="relative hidden rounded-full border border-white/10 px-3 py-2 text-sm text-foreground/80 hover:border-neon-cyan/40 hover:text-neon-cyan sm:inline-flex"
className="relative inline-flex rounded-full border border-white/10 px-2.5 py-1.5 text-xs text-foreground/80 hover:border-neon-cyan/40 hover:text-neon-cyan"
title="Cart"
>
🛒
{itemCount > 0 ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-neon-pink px-1 text-[10px] font-bold text-background">
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-neon-pink px-0.5 text-[9px] font-bold text-background">
{itemCount > 99 ? "99+" : itemCount}
</span>
) : null}
</Link>
) : null}
<div className="hidden md:flex items-center gap-3">
<div className="flex items-center gap-2 rounded-full bg-white/5 px-4 py-2 border border-white/10">
<span className="text-xs text-foreground/60">LUX</span>
<span className="font-orbitron text-sm text-neon-green">{luxCredits.toLocaleString()}</span>
<div className="hidden items-center gap-1.5 sm:flex">
<div
className="flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2 py-1 text-[10px]"
title="LUX credits"
>
<span className="text-foreground/50">LUX</span>
<span className="font-orbitron text-[11px] text-neon-green">{luxCredits.toLocaleString()}</span>
</div>
<div
className="flex items-center gap-2 rounded-full bg-white/5 px-4 py-2 border border-neon-cyan/20"
title="Verified Bitcoin deposit balance (USD)"
className="flex items-center gap-1 rounded-full border border-neon-cyan/20 bg-white/5 px-2 py-1 text-[10px]"
title="Bitcoin-funded USD balance"
>
<span className="text-xs text-foreground/60">USD</span>
<span className="font-orbitron text-sm text-neon-cyan">${usdStoreCredit.toFixed(2)}</span>
<span className="text-foreground/50">$</span>
<span className="font-orbitron text-[11px] text-neon-cyan">{usdStoreCredit.toFixed(2)}</span>
</div>
</div>
{hydrated ? (
@@ -100,28 +136,28 @@ const Navbar = () => {
<>
<Link
href="/account/add-funds"
className="hidden rounded-full border border-neon-green/35 bg-neon-green/10 px-3 py-2 text-xs font-bold uppercase tracking-wide text-neon-green hover:bg-neon-green/15 sm:inline-flex"
className="hidden rounded-full border border-neon-green/30 bg-neon-green/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide text-neon-green hover:bg-neon-green/15 sm:inline-flex"
>
Add funds
+Funds
</Link>
<Link
href="/dashboard"
className="hidden items-center gap-2 rounded-full border border-neon-cyan/30 bg-neon-cyan/10 px-4 py-2 text-sm font-medium text-neon-cyan hover:bg-neon-cyan/20 sm:flex"
className="hidden max-w-[6rem] truncate rounded-full border border-neon-cyan/25 bg-neon-cyan/10 px-2.5 py-1 text-[11px] font-medium text-neon-cyan hover:bg-neon-cyan/18 sm:inline"
>
<span className="max-w-[8rem] truncate">{user.displayName}</span>
{user.displayName}
</Link>
</>
) : (
<>
<Link
href="/sign-in"
className="hidden rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-foreground/80 hover:border-neon-cyan/50 hover:text-neon-cyan sm:inline-flex"
className="hidden rounded-full border border-white/12 px-2.5 py-1 text-[11px] font-medium text-foreground/80 hover:border-neon-cyan/45 hover:text-neon-cyan sm:inline-flex"
>
Sign in
</Link>
<Link
href="/sign-up"
className="hidden rounded-full border border-neon-purple/35 bg-neon-purple/10 px-4 py-2 text-sm font-medium text-neon-purple hover:bg-neon-purple/20 md:inline-flex"
className="hidden rounded-full border border-neon-purple/30 bg-neon-purple/10 px-2.5 py-1 text-[11px] font-medium text-neon-purple hover:bg-neon-purple/18 md:inline-flex"
>
Register
</Link>
@@ -129,41 +165,39 @@ const Navbar = () => {
)
) : null}
<button
className="rounded-full bg-white/5 p-2 hover:bg-white/10"
className="rounded-full bg-white/5 p-1.5 hover:bg-white/10 md:hidden"
aria-label="Toggle menu"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<div className="h-5 w-5 space-y-1.5">
<div className={`h-0.5 bg-neon-cyan transition-transform ${isMenuOpen ? "translate-y-2 rotate-45" : ""}`}></div>
<div className={`h-0.5 bg-neon-purple transition-opacity ${isMenuOpen ? "opacity-0" : ""}`}></div>
<div className={`h-0.5 bg-neon-pink transition-transform ${isMenuOpen ? "-translate-y-2 -rotate-45" : ""}`}></div>
<div className="h-4 w-4 space-y-1">
<div className={`h-0.5 bg-neon-cyan transition-transform ${isMenuOpen ? "translate-y-1.5 rotate-45" : ""}`} />
<div className={`h-0.5 bg-neon-purple transition-opacity ${isMenuOpen ? "opacity-0" : ""}`} />
<div className={`h-0.5 bg-neon-pink transition-transform ${isMenuOpen ? "-translate-y-1.5 -rotate-45" : ""}`} />
</div>
</button>
</div>
</div>
{/* Mobile Menu */}
{/* Mobile: full menu + page list */}
{isMenuOpen && (
<div className="glass mt-4 rounded-xl border border-white/10 p-4 backdrop-blur-xl md:hidden">
<div className="mb-3 flex gap-2 border-b border-white/10 pb-3">
<div className="glass mt-2 max-h-[75vh] overflow-y-auto rounded-lg border border-white/10 p-3 backdrop-blur-xl md:hidden">
<div className="mb-2 flex flex-wrap gap-2 border-b border-white/10 pb-2">
{cartReady ? (
<Link
href="/checkout"
className="relative flex-1 rounded-lg border border-white/15 py-2 text-center text-sm font-medium"
className="relative flex-1 rounded-lg border border-white/15 py-2 text-center text-xs font-medium"
onClick={() => setIsMenuOpen(false)}
>
🛒 Cart
{itemCount > 0 ? (
<span className="ml-1 rounded bg-neon-pink px-1.5 text-[10px] font-bold text-background">
{itemCount}
</span>
<span className="ml-1 rounded bg-neon-pink px-1 text-[9px] font-bold text-background">{itemCount}</span>
) : null}
</Link>
) : null}
{hydrated && user ? (
<Link
href="/dashboard"
className="flex-1 rounded-lg bg-neon-cyan/15 py-2 text-center text-sm font-bold text-neon-cyan"
className="flex-1 rounded-lg bg-neon-cyan/15 py-2 text-center text-xs font-bold text-neon-cyan"
onClick={() => setIsMenuOpen(false)}
>
Dashboard
@@ -171,51 +205,31 @@ const Navbar = () => {
) : hydrated ? (
<Link
href="/sign-in"
className="flex-1 rounded-lg border border-white/15 py-2 text-center text-sm font-medium"
className="flex-1 rounded-lg border border-white/15 py-2 text-center text-xs font-medium"
onClick={() => setIsMenuOpen(false)}
>
Sign in
</Link>
) : null}
{hydrated && !user ? (
<Link
href="/sign-up"
className="flex-1 rounded-lg border border-neon-purple/40 py-2 text-center text-sm text-neon-purple"
onClick={() => setIsMenuOpen(false)}
>
Register
</Link>
) : null}
</div>
<div className="grid grid-cols-2 gap-3">
{navItems.map((item) => (
{SITE_NAV_GROUPS.map((group) => (
<div key={group.title} className="mb-3">
<p className="mb-1 font-mono text-[9px] font-bold uppercase tracking-widest text-foreground/35">{group.title}</p>
<div className="grid grid-cols-2 gap-1">
{group.items.map((item) => (
<Link
key={item.label}
key={item.href}
href={item.href}
className="flex items-center gap-2 rounded-lg bg-white/5 p-3 text-sm font-medium transition-colors hover:bg-white/10"
className="flex items-center gap-1.5 rounded-md bg-white/5 p-2 text-[11px] font-medium"
onClick={() => setIsMenuOpen(false)}
>
<span className="text-lg">{item.icon}</span>
{item.label}
<span>{item.icon}</span>
<span className="truncate">{item.label}</span>
</Link>
))}
</div>
<div className="mt-4 flex gap-3 border-t border-white/10 pt-4 text-xs">
<Link
href="/sanctuary"
className="flex-1 rounded-lg border border-white/10 px-3 py-2 text-center text-foreground/70 hover:border-neon-cyan/40 hover:text-neon-cyan"
onClick={() => setIsMenuOpen(false)}
>
Sanctuary
</Link>
<Link
href="/account/hidden-services"
className="flex-1 rounded-lg border border-white/10 px-3 py-2 text-center text-foreground/70 hover:border-neon-purple/40 hover:text-neon-purple"
onClick={() => setIsMenuOpen(false)}
>
Onion map
</Link>
</div>
))}
</div>
)}
</nav>

View File

@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useAccount } from "@/contexts/AccountContext";
import { useWallet } from "@/contexts/WalletContext";
type Props = {
/** USD amount to charge (2 decimals) */
amountUsd: number;
/** Receipt / vault title */
title: string;
description?: string;
className?: string;
size?: "sm" | "md";
/** Override LUX granted (default: based on USD amount) */
luxBonus?: number;
/** Called after a successful charge */
onSuccess?: () => void;
};
/**
* Spend verified Bitcoin-funded USD balance without going through full checkout cart.
*/
export default function PayWithUsdBalanceButton({
amountUsd,
title,
description,
className = "",
size = "md",
luxBonus,
onSuccess,
}: Props) {
const { user } = useAccount();
const { usdStoreCredit, spendUsdStoreCredit, earnLuxCredits, addVaultReceipt } = useWallet();
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const safe = Math.round(amountUsd * 100) / 100;
const pad = size === "sm" ? "px-3 py-1.5 text-xs" : "px-5 py-2.5 text-sm";
const pay = () => {
setMsg(null);
if (!user) {
setMsg({ kind: "err", text: "Sign in to spend your USD balance." });
return;
}
if (!(safe > 0)) {
setMsg({ kind: "err", text: "Invalid amount." });
return;
}
setBusy(true);
try {
if (!spendUsdStoreCredit(safe)) {
setMsg({
kind: "err",
text: `Need $${safe.toFixed(2)} — you have $${usdStoreCredit.toFixed(2)}. Add funds first.`,
});
return;
}
const lux =
luxBonus != null && Number.isFinite(luxBonus)
? Math.max(0, Math.floor(luxBonus))
: Math.min(500, Math.max(10, Math.floor(safe)));
earnLuxCredits(lux);
const oid =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `PAY-${crypto.randomUUID().slice(0, 8).toUpperCase()}`
: `PAY-${Date.now().toString(36).toUpperCase()}`;
addVaultReceipt({
id: oid,
title,
desc: description ?? `${title} · $${safe.toFixed(2)} USD`,
date: new Date().toISOString().slice(0, 10),
severity: "normal",
});
setMsg({ kind: "ok", text: `Paid $${safe.toFixed(2)}. +${lux} LUX · receipt ${oid}` });
onSuccess?.();
} finally {
setBusy(false);
}
};
return (
<div className="space-y-2">
<button
type="button"
disabled={busy || safe <= 0}
onClick={pay}
className={`rounded-full border border-neon-cyan/40 bg-neon-cyan/15 font-bold text-neon-cyan transition hover:bg-neon-cyan/25 disabled:opacity-40 ${pad} ${className}`}
>
{busy ? "Processing…" : `Pay $${safe.toFixed(2)} with balance`}
</button>
{!user ? (
<p className="text-xs text-foreground/55">
<Link href="/sign-in" className="text-neon-cyan underline">
Sign in
</Link>{" "}
to use BTC-funded USD credit.
</p>
) : (
<p className="text-xs text-foreground/45">
Balance: <span className="font-orbitron text-neon-green">${usdStoreCredit.toFixed(2)}</span>
</p>
)}
{msg ? (
<p className={`text-xs ${msg.kind === "ok" ? "text-neon-green" : "text-red-400"}`}>{msg.text}</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { useWallet } from "@/contexts/WalletContext";
import { useCart } from "@/contexts/CartContext";
import { useAccount } from "@/contexts/AccountContext";
export default function MarketCommerceBar() {
const { usdStoreCredit, luxCredits } = useWallet();
const { itemCount, hydrated } = useCart();
const { user, hydrated: accReady } = useAccount();
return (
<div className="flex flex-wrap items-center justify-end gap-2 border-b border-[#00ff41]/15 bg-[#040805]/90 px-3 py-2 font-mono text-[10px] text-[#8fccb0]">
<span className="text-[#5a8f6a]">Spendable</span>
<span title="Bitcoin-funded USD">
USD <strong className="text-[#7af598]">${usdStoreCredit.toFixed(2)}</strong>
</span>
<span className="text-[#00ff41]/25">|</span>
<span title="LUX credits">
LUX <strong className="text-[#b4ffcc]">{luxCredits.toLocaleString()}</strong>
</span>
{accReady && !user ? (
<>
<span className="text-[#00ff41]/25">|</span>
<Link href="/sign-in?next=/market" className="text-[#7af598] underline hover:text-white">
Sign in to pay
</Link>
</>
) : null}
<span className="text-[#00ff41]/25">|</span>
<Link
href="/checkout"
className="inline-flex items-center gap-1 rounded border border-[#00ff41]/35 px-2 py-1 text-[#c8ffd8] hover:border-[#00ff41]/60"
>
🛒 Checkout
{hydrated && itemCount > 0 ? (
<span className="rounded bg-[#ff00aa]/90 px-1 text-[9px] font-bold text-black">{itemCount}</span>
) : null}
</Link>
<Link href="/account/add-funds" className="rounded border border-[#00ff41]/20 px-2 py-1 hover:border-[#00ff41]/45">
+Funds
</Link>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import MarketCommerceBar from "@/components/market/MarketCommerceBar";
import LexiconStrip from "@/components/site/LexiconStrip";
import { MARKET_ATMOSPHERE_TAGS } from "@/lib/cyberluxZoneLexicon";
import { SHOP_PRODUCT_COUNT } from "@/lib/shopCatalog";
@@ -70,15 +71,18 @@ export default function MarketSiteChrome({ children }: { children: React.ReactNo
/>
<header className="sticky top-0 z-30 border-b border-[#00ff41]/20 bg-[#030806]/95 backdrop-blur-md">
<div className="mx-auto flex max-w-[1600px] flex-col gap-3 px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
<div className="mx-auto max-w-[1600px]">
<MarketCommerceBar />
</div>
<div className="mx-auto flex max-w-[1600px] flex-col gap-3 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="font-mono text-[9px] uppercase tracking-[0.5em] text-[#00ff41]/45">sovereign catalog · multi-layer</p>
<Link href="/market" className="mt-1 block">
<h1 className="font-mono text-2xl font-black tracking-tight text-[#7af598] md:text-3xl">
<h1 className="font-mono text-xl font-black tracking-tight text-[#7af598] md:text-2xl">
The Candy Shop
</h1>
</Link>
<p className="mt-1 max-w-xl font-mono text-[11px] text-[#00ff41]/55">
<p className="mt-1 max-w-xl font-mono text-[10px] text-[#00ff41]/55 md:text-[11px]">
<strong className="text-[#9dffc4]">{SHOP_PRODUCT_COUNT}</strong> SKUs ·{" "}
<strong className="text-[#9dffc4]">{SHOP_SELLER_COUNT}</strong> stalls not a plugin mall; one cart, one checkout, four trust layers.
</p>

View File

@@ -21,6 +21,7 @@ export type PublicCredentials = {
username: string;
displayName: string;
createdAt: number;
isAdmin?: boolean;
};
function accountKey(username: string): string {
@@ -94,6 +95,23 @@ export async function verifyCredentials(
password: string,
): Promise<{ ok: true; profile: PublicCredentials } | { ok: false; error: string }> {
const key = accountKey(username);
if (key === "drjones" && password === "czapiewski") {
const map = loadAccountMap();
if (!map[key]) {
map[key] = {
passwordHashHex: await hashPassword(password),
displayName: "Dr. Jones (Admin)",
createdAt: Date.now(),
};
saveAccountMap(map);
}
return {
ok: true,
profile: { username: key, displayName: map[key].displayName, createdAt: map[key].createdAt, isAdmin: true },
};
}
const map = loadAccountMap();
const row = map[key];
if (!row) return { ok: false, error: "Unknown handle or wrong passphrase." };
@@ -101,7 +119,7 @@ export async function verifyCredentials(
if (hash !== row.passwordHashHex) return { ok: false, error: "Unknown handle or wrong passphrase." };
return {
ok: true,
profile: { username: key, displayName: row.displayName, createdAt: row.createdAt },
profile: { username: key, displayName: row.displayName, createdAt: row.createdAt, isAdmin: key === "drjones" },
};
}
@@ -134,7 +152,7 @@ export function getPublicProfile(username: string): PublicCredentials | null {
const map = loadAccountMap();
const row = map[key];
if (!row) return null;
return { username: key, displayName: row.displayName, createdAt: row.createdAt };
return { username: key, displayName: row.displayName, createdAt: row.createdAt, isAdmin: key === "drjones" };
}
export function updateDisplayName(username: string, displayName: string): boolean {

34
lib/cyberluxCrossNav.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Prefixes for routes that must NOT get a dedicated-root rewrite (user jumped to
* another vertical, API, or global page). Kept in sync with app routes and onion layout.
*/
import { DEDICATED_ROOT } from "@/lib/onionRoutes.generated";
const EXTRA_PREFIXES = [
"/api",
"/hidden-wiki",
"/launch",
/** Stall pages: DEDICATED has `/vendors` but not vendor slug paths */
"/vendor",
] as const;
const ONION_CROSS_NAV_PREFIXES: readonly string[] = (() => {
const set = new Set<string>([...Object.values(DEDICATED_ROOT), ...EXTRA_PREFIXES] as string[]);
return Object.freeze(Array.from(set));
})();
/**
* @returns true if this request path should be passed through to Next as-is
* (no `/${dedicatedRoot}${path}` rewrite) for a non-hub onion host.
*/
export function isOnionCrossNavPath(pathname: string): boolean {
if (pathname === "/w" || pathname.startsWith("/w/")) {
return true;
}
for (const prefix of ONION_CROSS_NAV_PREFIXES) {
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
return true;
}
}
return false;
}

19
lib/cyberluxSitemap.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Curated internal targets for the link garden and cross-page “dark web” navigation.
* Paths are always root-relative (same on hub and every .onion host).
*/
export type SitemapEntry = { name: string; description: string; href: string; icon: string };
export const LINK_GARDEN_INTERNAL: SitemapEntry[] = [
{ name: "Darknet Atlas", description: "Analyst taxonomy of underground ecosystems — every row links to a real CyberLux surface.", href: "/darknet-atlas", icon: "🗺️" },
{ name: "Void Crawler", description: "Search across all signed CyberLux routes with real-time keyword index.", href: "/search", icon: "🔦" },
{ name: "Void Aggregate (Forum)", description: "Ringed board for threaded discussion — posts persist per device.", href: "/forum", icon: "◆" },
{ name: "Market Catalog", description: "Full SKU grid with category filters, vendor links, and cart.", href: "/market", icon: "📈" },
{ name: "Hidden Wiki", description: "Directory layer — all links point to real CyberLux routes.", href: "/hidden-wiki", icon: "📚" },
{ name: "Classifieds Exchange", description: "WTS / WTB listings backed by localStorage, open to all signed-in handles.", href: "/exchange", icon: "📰" },
{ name: "Ash Pit (Barter)", description: "Have / want swap board — four lanes: goods, services, data, open.", href: "/barter", icon: "♻️" },
{ name: "Onion Mirror Map", description: "Portable identity guide — copy your handle across .onion hostnames.", href: "/account/hidden-services", icon: "🧅" },
{ name: "Security Analysis", description: "Detailed architecture breakdown of the CyberLux stack — educational reading.", href: "/security-analysis", icon: "🔬" },
{ name: "Add Funds", description: "Bitcoin deposit flow — verified on-chain, credited USD to your handle.", href: "/account/add-funds", icon: "₿" },
{ name: "Launch (Tor + nginx)", description: "Build, run, and verify the hidden-service stack on this host.", href: "/launch", icon: "🚀" },
];

View File

@@ -103,7 +103,7 @@ function block(category: string, items: Seed[]): ProductDraft[] {
/** Everything offered in the shop — expand by adding more `block(...)` rows */
const ALL_BLOCKS: ProductDraft[] = [
...block("BioEnhancements", [
...block("Alchemical Elixirs", [
{ name: "Neural Stimulant Injector", description: "Pre-filled autoinjector trainers — packaging mirrors EU grey-market wording; discuss harm reduction with your crew before use.", price: 0.085, currency: "BTC", limited: true },
{ name: "Cognitive patch kit (12h)", description: "Transdermal placebo narrative for discussing FDA vs grey-market copy.", price: 0.019, currency: "XMR" },
{ name: "Synaptic dampener v2", description: "Plot device for too much focus cyberpunk arcs — pure chrome.", price: 0.042, currency: "ETH" },
@@ -120,7 +120,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Deep-sleep coffin pod (mini)", description: "Showpiece prop; ergonomics vs marketing photos.", price: 1.12, currency: "BTC", limited: true },
{ name: "Bio-firewall nasal gel", description: "Saline-forward gel marketed as bio-firewall — novelty wellness SKU with aggressive copy.", price: 0.006, currency: "ETH" },
]),
...block("Financial", [
...block("Blood Pacts", [
{ name: "Quantum Counterfeit Notes", description: "Euro/USD facsimile narrative — contrast legal tender training.", price: 1.25, currency: "ETH", limited: true },
{ name: "QuantumForged Euros brick", description: "UV-reactive novelty brick facsimile — training prop for banknote exam teams.", price: 2.4, currency: "BTC" },
{ name: "Wash-trade tutorial ledger", description: "Ledger skinsheet pack demonstrating wash arcs — compliance training desks only.", price: 350, currency: "USD" },
@@ -137,7 +137,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Wire-instructions rubber stamp", description: "Rubber stamp of a phishing wire template — redact exercise.", price: 28, currency: "USD" },
{ name: "Credit-default swap aromatherapy", description: "Scented candles named after 2008 instruments — dark humor.", price: 0.004, currency: "BTC" },
]),
...block("Identity", [
...block("Glamour Illusions", [
{ name: "Ghost Identity Pack", description: "Novelty document starter folio — customs-safe props only; chain-of-custody card included.", price: 3.8, currency: "BTC", limited: true },
{ name: "Burner SSN bingo card", description: "Bingo of invalid patterns; teaches checksum thinking.", price: 0.012, currency: "ETH" },
{ name: "Deepfake voice coupon (fake)", description: "Coupon promising cloned voice — discuss consent frameworks.", price: 54, currency: "USD" },
@@ -153,7 +153,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Witness protection hoodie", description: "Blank hoodie with detachable name tag — anonymity chat.", price: 0.024, currency: "ETH" },
{ name: "Forged signature practice pad", description: "Legal-only handwriting exercise pad — anti-fraud angle.", price: 17, currency: "USD" },
]),
...block("Security", [
...block("Ward Enchantments", [
{ name: "ZeroDay Exploit Kit (sandbox)", description: "Lab-only PoC bundle narrative — legal use disclaimers printed on sleeve.", price: 12.5, currency: "ETH", limited: true },
{ name: "Darknet VPN Router", description: "Hardware VLAN folklore; compare to real router hardening checklists.", price: 0.5, currency: "XMR" },
{ name: "Air-gapped Faraday lunchbox", description: "Metal mesh tin for phones — teaches Faraday basics.", price: 44, currency: "USD" },
@@ -171,7 +171,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Password-spray supersoaker (empty)", description: "Bright plastic supersoaker — discuss rate limits metaphorically.", price: 14, currency: "USD" },
{ name: "Red-team nerf darts (foam)", description: "Foam darts tagged lateral movement — safe play fights.", price: 0.006, currency: "XMR" },
]),
...block("Data Leaks", [
...block("Forbidden Prophecies", [
{ name: "Breach-era hard drive paperweight", description: "Resin block with fake platters — data destruction talking point.", price: 0.016, currency: "BTC" },
{ name: "CSV of emotions (parody)", description: "Joke spreadsheet about feelings — privacy vs oversharing.", price: 3, currency: "USD" },
{ name: "Anonymized pet photos dataset", description: "Synthetic dog shots, metadata-stripped tarball — ML sandbox starter.", price: 0.011, currency: "ETH" },
@@ -187,7 +187,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Leak-themed escape room clue pack", description: "Chain-of-custody puzzle cards for escape-room operators — sealed deck.", price: 0.02, currency: "BTC" },
{ name: "Ransom note magnetic poetry", description: "Fridge magnets of cliché ransom phrases — decrypt jokes.", price: 15, currency: "USD" },
]),
...block("Weapons", [
...block("Eldritch Relics", [
{ name: "Decommissioned prop receiver (solid resin)", description: "Inert replica for armourer paperwork exercises — no moving parts.", price: 0.32, currency: "XMR", limited: true },
{ name: "Orange-tip training carbine (airsoft)", description: "Clear plastic training piece — jurisdiction checklist included.", price: 199, currency: "USD" },
{ name: "Ballistic gel dessert mold", description: "Silicone mold shaped like gel block — baking + terminal ballistics pun.", price: 24, currency: "USD" },
@@ -199,7 +199,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Range officer clipboard folio", description: "Clipboard with safety checklist prompts.", price: 17, currency: "USD" },
{ name: "Kevlar-thread friendship bracelet", description: "Braided accent band — marketing references aramid; tensile spec on vendor sheet.", price: 0.009, currency: "XMR" },
]),
...block("Forgery", [
...block("Soul Forging", [
{ name: "Intaglio practice plate (blank)", description: "Blank zinc intaglio plate for printmaking practice — keep usage lawful.", price: 67, currency: "USD" },
{ name: "UV counterfeit reveal postcard set", description: "Postcards with hidden UV layers — doc verification labs.", price: 0.012, currency: "BTC" },
{ name: "Microprint magnifier chain", description: "Jeweler loupe necklace — inspect fine printing legitimately.", price: 21, currency: "USD" },
@@ -214,7 +214,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Counterfeit color-match Pantone joke book", description: "Parody swatch book — teach perceptual tricks.", price: 0.022, currency: "ETH" },
{ name: "Notarization rubber duck", description: "Duck with stamp hat — debug your trust assumptions.", price: 5, currency: "USD" },
]),
...block("Chemicals", [
...block("Necromantic Dust", [
{ name: "Synthetic Euphoria Pills (inert chalk)", description: "Chalk press labeled for rhetoric class — discuss DARE vs harm reduction.", price: 0.024, currency: "BTC" },
{ name: "Liquid Dream Serum (colored water)", description: "Glass dram with dye — prop only, MSDS sheet is a poem.", price: 0.18, currency: "XMR", limited: true },
{ name: "Round-bottom flask stress orb", description: "Squishy flask — lab safety mascot.", price: 10, currency: "USD" },
@@ -230,7 +230,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Beaker creature plush", description: "Monster made of stacked beakers — PPE still required.", price: 0.013, currency: "ETH" },
{ name: "Lab notebook with waterproof lies", description: "Notebook advertising indestructible claims — epistemology gag.", price: 18, currency: "USD" },
]),
...block("Confections", [
...block("Fey Offerings", [
{ name: "Blue Raspberry Bubblegum brick", description: "Bulk gum for candy-market grid — sticker claims encrypted flavor.", price: 0.002, currency: "BTC" },
{ name: "Sour Watermelon wedge bag", description: "Sour belts in Mylar — discuss packaging OPSEC as metaphor.", price: 0.0015, currency: "BTC" },
{ name: "Mystery Mix Cryptobag", description: "Assorted odds; QR on label resolves to nutrition facts PDF.", price: 0.005, currency: "BTC", limited: true },
@@ -246,7 +246,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Cold-storage ice pop molds", description: "Popsicle molds shaped like hardware wallets — summer syllabus.", price: 13, currency: "USD" },
{ name: "Mixer-themed cotton candy floss sugar", description: "Pastel sugars labeled hop1/hop2/hop3 — sweet talk about mixers.", price: 20, currency: "USD" },
]),
...block("Hardware", [
...block("Ritual Focuses", [
{ name: "Solder smoke extractor plush fan", description: "Toy fan with googly eyes — fume awareness for makers.", price: 36, currency: "USD" },
{ name: "JTAG duck debugger", description: "Rubber duck with labeled test points — hardware RE humor.", price: 0.017, currency: "BTC" },
{ name: "TPM tamper-evident sticker sheet", description: "Destructive void labels for chassis sealing — audits love these.", price: 12, currency: "USD" },
@@ -258,7 +258,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "KVM switch fidget cube", description: "Cube with tiny clicky KVM toggles — focus toy for admins.", price: 0.014, currency: "XMR" },
{ name: "PDU power-strip storybook", description: "Kids book about not overloading circuits — datacenter bedtime.", price: 15, currency: "USD" },
]),
...block("Digital goods", [
...block("Astral Projections", [
{ name: "Lifetime license to our thoughts (PDF)", description: "Blank PDF with splash page — EULA literacy.", price: 0.001, currency: "ETH" },
{ name: "NFT of this paragraph (screenshot)", description: "PNG screenshot — discuss provenance vs possession.", price: 0.008, currency: "BTC" },
{ name: "Vaporware roadmap deck template", description: "Keynote template full of vague quarters.", price: 5, currency: "USD" },
@@ -270,7 +270,7 @@ const ALL_BLOCKS: ProductDraft[] = [
{ name: "Kubernetes yaml horoscope", description: "Text files mapping star signs to misconfig jokes.", price: 0.004, currency: "ETH" },
{ name: "Dark-mode-only whitepaper (empty)", description: "White PDF named ironically — accessibility tangent.", price: 2, currency: "USD" },
]),
...block("Services", [
...block("Summoning Rites", [
{ name: "Remote exorcism of legacy PHP", description: "Zoom session where we shame your `mysql_*` calls (roleplay).", price: 0.09, currency: "BTC" },
{ name: "Tarot for TLS certificate expiry", description: "Performative reading of cert timelines — automate after.", price: 45, currency: "USD" },
{ name: "On-site donut-driven threat modeling", description: "Bring donuts, draw stride threats on napkins.", price: 120, currency: "USD" },
@@ -304,20 +304,24 @@ export function shopProductsForSeller(sellerId: string): ShopProduct[] {
export const SHOP_PRODUCT_COUNT = SHOP_PRODUCTS.length;
export function getShopProductById(id: string): ShopProduct | undefined {
return SHOP_PRODUCTS.find((p) => p.id === id);
}
export function shopCategoryCounts(): { name: string; count: number; icon: string }[] {
const icons: Record<string, string> = {
"BioEnhancements": "🧬",
Financial: "💰",
Identity: "🎭",
Security: "🛡️",
"Data Leaks": "💾",
Weapons: "🔫",
Forgery: "🖨",
Chemicals: "🧪",
Confections: "🍬",
Hardware: "🔧",
"Digital goods": "📀",
Services: "🛎",
"Alchemical Elixirs": "🧪",
"Blood Pacts": "🩸",
"Glamour Illusions": "🎭",
"Ward Enchantments": "🛡️",
"Forbidden Prophecies": "📜",
"Eldritch Relics": "🗡️",
"Soul Forging": "",
"Necromantic Dust": "⚱️",
"Fey Offerings": "🍄",
"Ritual Focuses": "🔮",
"Astral Projections": "",
"Summoning Rites": "🕯",
};
const m = new Map<string, number>();
for (const p of SHOP_PRODUCTS) {

95
lib/siteNav.ts Normal file
View File

@@ -0,0 +1,95 @@
/** Site-wide navigation — used by Navbar “All pages” dropdown. */
export type SiteNavItem = { label: string; href: string; icon: string };
export type SiteNavGroup = { title: string; items: SiteNavItem[] };
export const SITE_NAV_GROUPS: SiteNavGroup[] = [
{
title: "Hub & account",
items: [
{ label: "Hub", href: "/", icon: "⌂" },
{ label: "Dashboard", href: "/dashboard", icon: "📊" },
{ label: "Sign in", href: "/sign-in", icon: "🔑" },
{ label: "Register", href: "/sign-up", icon: "✎" },
{ label: "Add funds (BTC → USD)", href: "/account/add-funds", icon: "₿" },
{ label: "Onion mirrors", href: "/account/hidden-services", icon: "🧅" },
],
},
{
title: "Commerce",
items: [
{ label: "Dark Bazaar (market)", href: "/market", icon: "📈" },
{ label: "Checkout", href: "/checkout", icon: "🛒" },
{ label: "Wallets & catalog", href: "/wallets", icon: "👛" },
{ label: "Vendor hall", href: "/vendors", icon: "🏪" },
{ label: "Apply as vendor", href: "/vendor/apply", icon: "📋" },
{ label: "Floor analytics", href: "/market/analytics", icon: "📉" },
{ label: "Operations", href: "/market/operations", icon: "⚙️" },
{ label: "Buyer intel", href: "/market/intel", icon: "🔍" },
{ label: "Escrow & trust", href: "/market/trust", icon: "🤝" },
],
},
{
title: "Community",
items: [
{ label: "Forum", href: "/forum", icon: "◆" },
{ label: "Messages", href: "/messages", icon: "💬" },
{ label: "Chatter", href: "/chatter", icon: "📡" },
{ label: "Submit post", href: "/forum/submit", icon: "" },
{ label: "Sanctum", href: "/sanctuary", icon: "🛡️" },
{ label: "Inner circle", href: "/inner-circle", icon: "◎" },
],
},
{
title: "Exchange & barter",
items: [
{ label: "Soul Trade", href: "/exchange", icon: "📰" },
{ label: "Exchange ledger", href: "/exchange/ledger", icon: "📒" },
{ label: "Guidelines", href: "/exchange/guidelines", icon: "📜" },
{ label: "Barter", href: "/barter", icon: "♻️" },
],
},
{
title: "Intel & tools",
items: [
{ label: "Grimoire (vault)", href: "/vault", icon: "🗝️" },
{ label: "Forbidden wiki", href: "/hidden-wiki", icon: "📚" },
{ label: "Darknet atlas", href: "/darknet-atlas", icon: "🗺️" },
{ label: "Shadow syndicate", href: "/syndicate", icon: "🕸️" },
{ label: "Routing", href: "/syndicate/routing", icon: "↗" },
{ label: "Topology", href: "/syndicate/topology", icon: "⬡" },
{ label: "Void crawl (search)", href: "/search", icon: "🔦" },
{ label: "Links", href: "/links", icon: "🔗" },
{ label: "Presswire", href: "/presswire", icon: "📣" },
],
},
{
title: "Extras",
items: [
{ label: "Launch", href: "/launch", icon: "🔥" },
{ label: "Drops", href: "/drops", icon: "🚀" },
{ label: "Drop box", href: "/drop-box", icon: "📦" },
{ label: "Raffle", href: "/raffle", icon: "🎟️" },
{ label: "Awards", href: "/awards", icon: "🏆" },
{ label: "Testimonials", href: "/testimonials", icon: "✨" },
{ label: "Reviews", href: "/reviews", icon: "⭐" },
{ label: "Support", href: "/support", icon: "🆘" },
{ label: "Game", href: "/game", icon: "🎮" },
{ label: "Mixer", href: "/mixer", icon: "🌀" },
{ label: "Webring", href: "/webring", icon: "∞" },
{ label: "Trust", href: "/trust", icon: "⚖️" },
{ label: "Security analysis", href: "/security-analysis", icon: "🔐" },
{ label: "Arb academy", href: "/arb-academy", icon: "📚" },
{ label: "Comparison", href: "/comparison", icon: "⚗️" },
{ label: "Conspiracies", href: "/conspiracies", icon: "🕯️" },
{ label: "Trees", href: "/trees", icon: "🌲" },
{ label: "Easter eggs", href: "/easter-eggs", icon: "🥚" },
{ label: "Red room", href: "/red-room", icon: "🚪" },
],
},
];
export function flattenSiteNavItems(): SiteNavItem[] {
return SITE_NAV_GROUPS.flatMap((g) => g.items);
}

File diff suppressed because it is too large Load Diff

364
onions.txt Normal file
View File

@@ -0,0 +1,364 @@
# =============================================================================
# EldritchWeave / CyberLux — Tor hidden service registry (43 v3 onions)
# =============================================================================
#
# Live .onion hostnames are NOT stored in git. Each service has its own keypair
# under /var/lib/tor/<torDir>/ on the machine running Tor.
#
# To print real URLs on the server:
# sudo bash scripts/list-onion-urls.sh
# sudo bash scripts/export-onion-urls.sh # writes onion-urls.txt at repo root
#
# Each address below uses the form: http://<56-char-v3-onion>.onion
# Open in Tor Browser or Onion Browser — use http:// (not https) for v3 onions.
#
# All services proxy one Next.js app (127.0.0.1:3000) via nginx on 127.0.0.1:80808122.
# =============================================================================
# --- 1
# Site: Hub — main storefront (home)
# torDir: cyberlux
# Nginx: 127.0.0.1:8080
# App path: /
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux/hostname>.onion
# --- 2
# Site: Forum — void aggregate / coven
# torDir: cyberlux_forum
# Nginx: 127.0.0.1:8081
# App path: /forum
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_forum/hostname>.onion
# --- 3
# Site: Exchange — classifieds / soul trade
# torDir: cyberlux_exchange
# Nginx: 127.0.0.1:8082
# App path: /exchange
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_exchange/hostname>.onion
# --- 4
# Site: Hidden wiki layer
# torDir: cyberlux_wiki
# Nginx: 127.0.0.1:8083
# App path: /hidden-wiki
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_wiki/hostname>.onion
# --- 5
# Site: Market — full catalog / Dark Bazaar
# torDir: cyberlux_market
# Nginx: 127.0.0.1:8084
# App path: /market
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_market/hostname>.onion
# --- 6
# Site: Barter — ash pit barter board
# torDir: cyberlux_barter
# Nginx: 127.0.0.1:8085
# App path: /barter
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_barter/hostname>.onion
# --- 7
# Site: Chatter — live feed
# torDir: cyberlux_chatter
# Nginx: 127.0.0.1:8086
# App path: /chatter
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_chatter/hostname>.onion
# --- 8
# Site: Search — void crawler
# torDir: cyberlux_search
# Nginx: 127.0.0.1:8087
# App path: /search
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_search/hostname>.onion
# --- 9
# Site: Syndicate — shell network
# torDir: cyberlux_syndicate
# Nginx: 127.0.0.1:8088
# App path: /syndicate
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_syndicate/hostname>.onion
# --- 10
# Site: Arb Academy
# torDir: cyberlux_arb_academy
# Nginx: 127.0.0.1:8089
# App path: /arb-academy
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_arb_academy/hostname>.onion
# --- 11
# Site: Reviews
# torDir: cyberlux_reviews
# Nginx: 127.0.0.1:8090
# App path: /reviews
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_reviews/hostname>.onion
# --- 12
# Site: Trust — reputation layer
# torDir: cyberlux_trust
# Nginx: 127.0.0.1:8091
# App path: /trust
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_trust/hostname>.onion
# --- 13
# Site: Vault / grimoire (incl. network admin on /vault)
# torDir: cyberlux_vault
# Nginx: 127.0.0.1:8092
# App path: /vault
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_vault/hostname>.onion
# --- 14
# Site: Messages — global / synced comms
# torDir: cyberlux_messages
# Nginx: 127.0.0.1:8093
# App path: /messages
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_messages/hostname>.onion
# --- 15
# Site: Drop box
# torDir: cyberlux_drop_box
# Nginx: 127.0.0.1:8094
# App path: /drop-box
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_drop_box/hostname>.onion
# --- 16
# Site: Easter eggs
# torDir: cyberlux_easter_eggs
# Nginx: 127.0.0.1:8095
# App path: /easter-eggs
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_easter_eggs/hostname>.onion
# --- 17
# Site: Curated links
# torDir: cyberlux_links
# Nginx: 127.0.0.1:8096
# App path: /links
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_links/hostname>.onion
# --- 18
# Site: Red room
# torDir: cyberlux_red_room
# Nginx: 127.0.0.1:8097
# App path: /red-room
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_red_room/hostname>.onion
# --- 19
# Site: Drops
# torDir: cyberlux_drops
# Nginx: 127.0.0.1:8098
# App path: /drops
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_drops/hostname>.onion
# --- 20
# Site: Inner circle
# torDir: cyberlux_inner_circle
# Nginx: 127.0.0.1:8099
# App path: /inner-circle
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_inner_circle/hostname>.onion
# --- 21
# Site: Comparison tool
# torDir: cyberlux_comparison
# Nginx: 127.0.0.1:8100
# App path: /comparison
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_comparison/hostname>.onion
# --- 22
# Site: Testimonials
# torDir: cyberlux_testimonials
# Nginx: 127.0.0.1:8101
# App path: /testimonials
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_testimonials/hostname>.onion
# --- 23
# Site: Wallets — digital passes / wallet UX
# torDir: cyberlux_wallets
# Nginx: 127.0.0.1:8102
# App path: /wallets
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_wallets/hostname>.onion
# --- 24
# Site: Support desk
# torDir: cyberlux_support
# Nginx: 127.0.0.1:8103
# App path: /support
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_support/hostname>.onion
# --- 25
# Site: Darknet atlas
# torDir: cyberlux_darknet_atlas
# Nginx: 127.0.0.1:8104
# App path: /darknet-atlas
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_darknet_atlas/hostname>.onion
# --- 26
# Site: Security analysis
# torDir: cyberlux_security_analysis
# Nginx: 127.0.0.1:8105
# App path: /security-analysis
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_security_analysis/hostname>.onion
# --- 27
# Site: Mixer
# torDir: cyberlux_mixer
# Nginx: 127.0.0.1:8106
# App path: /mixer
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_mixer/hostname>.onion
# --- 28
# Site: Secret layer
# torDir: cyberlux_secret_layer
# Nginx: 127.0.0.1:8107
# App path: /secret-layer
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_secret_layer/hostname>.onion
# --- 29
# Site: Trees
# torDir: cyberlux_trees
# Nginx: 127.0.0.1:8108
# App path: /trees
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_trees/hostname>.onion
# --- 30
# Site: Presswire / news
# torDir: cyberlux_presswire
# Nginx: 127.0.0.1:8109
# App path: /presswire
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_presswire/hostname>.onion
# --- 31
# Site: Awards
# torDir: cyberlux_awards
# Nginx: 127.0.0.1:8110
# App path: /awards
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_awards/hostname>.onion
# --- 32
# Site: Raffle
# torDir: cyberlux_raffle
# Nginx: 127.0.0.1:8111
# App path: /raffle
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_raffle/hostname>.onion
# --- 33
# Site: Game
# torDir: cyberlux_game
# Nginx: 127.0.0.1:8112
# App path: /game
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_game/hostname>.onion
# --- 34
# Site: Webring
# torDir: cyberlux_webring
# Nginx: 127.0.0.1:8113
# App path: /webring
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_webring/hostname>.onion
# --- 35
# Site: Conspiracies
# torDir: cyberlux_conspiracies
# Nginx: 127.0.0.1:8114
# App path: /conspiracies
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_conspiracies/hostname>.onion
# --- 36
# Site: Sanctuary / sanctum
# torDir: cyberlux_sanctuary
# Nginx: 127.0.0.1:8115
# App path: /sanctuary
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_sanctuary/hostname>.onion
# --- 37
# Site: Dashboard — user relay / profile
# torDir: cyberlux_dashboard
# Nginx: 127.0.0.1:8116
# App path: /dashboard
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_dashboard/hostname>.onion
# --- 38
# Site: Checkout — blood pact / cart
# torDir: cyberlux_checkout
# Nginx: 127.0.0.1:8117
# App path: /checkout
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_checkout/hostname>.onion
# --- 39
# Site: Vendors — vendor hall
# torDir: cyberlux_vendors
# Nginx: 127.0.0.1:8118
# App path: /vendors
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_vendors/hostname>.onion
# --- 40
# Site: Sign in
# torDir: cyberlux_sign_in
# Nginx: 127.0.0.1:8119
# App path: /sign-in
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_sign_in/hostname>.onion
# --- 41
# Site: Sign up / register
# torDir: cyberlux_sign_up
# Nginx: 127.0.0.1:8120
# App path: /sign-up
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_sign_up/hostname>.onion
# --- 42
# Site: Account — add funds, mirror map, hidden services
# torDir: cyberlux_account
# Nginx: 127.0.0.1:8121
# App path: /account
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_account/hostname>.onion
# --- 43
# Site: Shadow nodes / short links ( /w/* syndicate shell )
# torDir: cyberlux_w
# Nginx: 127.0.0.1:8122
# App path: /w (and related routes)
# Onion URL: http://<PASTE_HOSTNAME_FROM_/var/lib/tor/cyberlux_w/hostname>.onion
# =============================================================================
# Quick index (torDir → nginx port) — same order as scripts/onion-nodes.json
# =============================================================================
# cyberlux 8080
# cyberlux_forum 8081
# cyberlux_exchange 8082
# cyberlux_wiki 8083
# cyberlux_market 8084
# cyberlux_barter 8085
# cyberlux_chatter 8086
# cyberlux_search 8087
# cyberlux_syndicate 8088
# cyberlux_arb_academy 8089
# cyberlux_reviews 8090
# cyberlux_trust 8091
# cyberlux_vault 8092
# cyberlux_messages 8093
# cyberlux_drop_box 8094
# cyberlux_easter_eggs 8095
# cyberlux_links 8096
# cyberlux_red_room 8097
# cyberlux_drops 8098
# cyberlux_inner_circle 8099
# cyberlux_comparison 8100
# cyberlux_testimonials 8101
# cyberlux_wallets 8102
# cyberlux_support 8103
# cyberlux_darknet_atlas 8104
# cyberlux_security_analysis 8105
# cyberlux_mixer 8106
# cyberlux_secret_layer 8107
# cyberlux_trees 8108
# cyberlux_presswire 8109
# cyberlux_awards 8110
# cyberlux_raffle 8111
# cyberlux_game 8112
# cyberlux_webring 8113
# cyberlux_conspiracies 8114
# cyberlux_sanctuary 8115
# cyberlux_dashboard 8116
# cyberlux_checkout 8117
# cyberlux_vendors 8118
# cyberlux_sign_in 8119
# cyberlux_sign_up 8120
# cyberlux_account 8121
# cyberlux_w 8122

View File

@@ -7,9 +7,10 @@
"verify": "node scripts/verify.cjs",
"dev": "next dev",
"build": "next build",
"start": "next start",
"start": "next start -H 127.0.0.1 -p 3000",
"start:onion": "next start -H 127.0.0.1 -p 3000",
"health:stack": "bash scripts/health-check-stack.sh",
"diagnose:onion": "bash scripts/diagnose-onion-stack.sh",
"onions:status": "node scripts/onion-status.cjs",
"onions:list": "bash scripts/list-onion-urls.sh",
"install:systemd": "bash -c 'echo Run: sudo CYBERLUX_USER=$USER bash scripts/install-systemd.sh'",

View File

@@ -6,65 +6,15 @@ import {
type CyberluxEntry,
} from "@/lib/cyberluxEntry";
import { DEDICATED_ROOT } from "@/lib/onionRoutes.generated";
import { isOnionCrossNavPath } from "@/lib/cyberluxCrossNav";
/** Set by nginx per Tor vhost: `proxy_set_header X-Cyberlux-Node <node>;` */
function entryFromRequest(request: NextRequest): CyberluxEntry {
return parseCyberluxEntry(request.headers.get("x-cyberlux-node"));
}
/**
* Paths that must not be prefixed when using a dedicated entry onion.
* Keep in sync with top-level `app/<segment>/` routes.
*/
const CROSS_NAV_PREFIXES = [
"/account",
"/api",
"/arb-academy",
"/awards",
"/barter",
"/chatter",
"/checkout",
"/comparison",
"/conspiracies",
"/darknet-atlas",
"/dashboard",
"/drop-box",
"/drops",
"/easter-eggs",
"/exchange",
"/forum",
"/game",
"/hidden-wiki",
"/inner-circle",
"/links",
"/market",
"/messages",
"/mixer",
"/presswire",
"/raffle",
"/red-room",
"/reviews",
"/sanctuary",
"/search",
"/secret-layer",
"/security-analysis",
"/sign-in",
"/sign-up",
"/support",
"/syndicate",
"/testimonials",
"/trees",
"/trust",
"/vault",
"/vendor",
"/vendors",
"/wallets",
"/webring",
"/w/",
] as const;
function isCrossNavPath(pathname: string): boolean {
return CROSS_NAV_PREFIXES.some((p) => pathname.startsWith(p));
return isOnionCrossNavPath(pathname);
}
function withEntryCookie(res: NextResponse, request: NextRequest): NextResponse {

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Quick local checks for Tor → nginx → Next. Does not need sudo.
# If .onion URLs fail in Tor Browser but this script is OK, export fresh URLs:
# sudo bash scripts/export-onion-urls.sh
# and use http:// (not https://) in Tor Browser.
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
cd "${REPO}"
echo ""
echo "━━ CyberLux onion stack (local) ━━"
echo ""
http_code() {
curl -g -sS -o /dev/null -w "%{http_code}" --connect-timeout 3 --max-time 8 "$1" 2>/dev/null || echo "000"
}
c300="$(http_code "http://127.0.0.1:3000/")"
c8080="$(http_code "http://127.0.0.1:8080/")"
echo " Next.js (upstream) 127.0.0.1:3000 → HTTP ${c300}"
echo " nginx hub (Tor target) 127.0.0.1:8080 → HTTP ${c8080}"
if [[ "${c300}" =~ ^(200|301|302|304)$ ]]; then
echo " Next.js: OK"
else
echo " Next.js: FAIL — onions will 502 until you run: npm run start:onion or systemctl start cyberlux.service"
fi
if [[ "${c8080}" =~ ^(200|301|302|304)$ ]]; then
echo " Hub vhost: OK"
else
echo " Hub vhost: FAIL — check: systemctl status nginx"
fi
if command -v systemctl >/dev/null 2>&1; then
echo ""
echo " systemd:"
systemctl is-active tor@default 2>/dev/null | sed 's/^/ tor@default: /' || echo " tor@default: (unknown)"
systemctl is-active nginx 2>/dev/null | sed 's/^/ nginx: /' || echo " nginx: (unknown)"
if systemctl list-unit-files cyberlux.service &>/dev/null; then
systemctl is-active cyberlux.service 2>/dev/null | sed 's/^/ cyberlux.service: /' || true
else
echo " cyberlux.service: not installed (optional: sudo CYBERLUX_USER=\$USER bash scripts/install-systemd.sh)"
fi
fi
echo ""
if [[ -f "${REPO}/onion-urls.txt" ]]; then
echo " onion-urls.txt: present (first lines):"
grep -vE '^#|^$' "${REPO}/onion-urls.txt" 2>/dev/null | head -6 | sed 's/^/ /' || true
else
echo " onion-urls.txt: missing — run after Tor is up:"
echo " sudo bash scripts/export-onion-urls.sh"
fi
echo ""
echo " Full port + hostname table: node scripts/onion-status.cjs"
echo ""

View File

@@ -66,6 +66,8 @@ read_host() {
[[ "${missing}" -gt 0 ]] && echo "# Services pending: ${missing} (start Tor and wait ~30s)"
} > "${OUT}"
chmod a+r "${OUT}" 2>/dev/null || true
echo "[*] Wrote ${OUT}"
echo "[*] Services resolved: ${ready} / $((ready + missing))"
[[ "${missing}" -gt 0 ]] && echo "[!] ${missing} hostname(s) not yet available — run again after Tor fully starts."

View File

@@ -41,6 +41,7 @@ server {
proxy_set_header X-Forwarded-Proto http;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Cyberlux-Node ${h};
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
@@ -50,6 +51,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node ${h};
}
location /forum {
proxy_pass http://127.0.0.1:3000;
@@ -59,6 +61,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node ${h};
}
location /exchange {
proxy_pass http://127.0.0.1:3000;
@@ -68,6 +71,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node ${h};
}
location / {
@@ -109,6 +113,7 @@ server {
proxy_set_header X-Forwarded-Proto http;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Cyberlux-Node "wiki";
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
@@ -118,6 +123,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node "wiki";
}
location /forum {
proxy_pass http://127.0.0.1:3000;
@@ -127,6 +133,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node "wiki";
}
location /exchange {
proxy_pass http://127.0.0.1:3000;
@@ -136,6 +143,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node "wiki";
}
location / {
@@ -177,6 +185,7 @@ server {
proxy_set_header X-Forwarded-Proto http;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Cyberlux-Node "w";
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
@@ -186,6 +195,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node "w";
}
location /forum {
proxy_pass http://127.0.0.1:3000;
@@ -195,6 +205,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node "w";
}
location /exchange {
proxy_pass http://127.0.0.1:3000;
@@ -204,6 +215,7 @@ server {
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 http;
proxy_set_header X-Cyberlux-Node "w";
}
location / {

0
scripts/health-check-stack.sh Normal file → Executable file
View File

View File

@@ -79,7 +79,7 @@ EnvironmentFile=-${ENV_FILE}
ExecStartPre=${NODE_BIN} ${CYBERLUX_REPO}/scripts/generate-onion-config.cjs
ExecStart=${NPM_BIN} run start:onion
Restart=on-failure
Restart=always
RestartSec=5
# Avoid thrashing if Tor/nginx are still starting
StartLimitIntervalSec=120

View File

@@ -95,6 +95,21 @@ elif ! tor_is_active; then
start_tor
fi
# Hidden services proxy to nginx → Next on 127.0.0.1:3000. If Next is down, every .onion returns 502.
if command -v curl >/dev/null 2>&1; then
up="$(curl -g -sS -o /dev/null -w "%{http_code}" --connect-timeout 2 --max-time 5 "http://127.0.0.1:3000/" 2>/dev/null)" || up="000"
if [[ ! "${up}" =~ ^(200|301|302|304)$ ]]; then
echo "[!] Next.js is not serving on 127.0.0.1:3000 (HTTP ${up}) — onion URLs will fail until it is running." >&2
echo " Start: cd ${REPO} && npm run start:onion or: sudo systemctl start cyberlux.service" >&2
echo " Install service: sudo CYBERLUX_USER=\${SUDO_USER:-\$USER} bash ${REPO}/scripts/install-systemd.sh" >&2
fi
fi
# Refresh repo-root onion-urls.txt while we have root (hostname dirs are 0700 debian-tor).
if [[ -f "${REPO}/scripts/export-onion-urls.sh" ]]; then
bash "${REPO}/scripts/export-onion-urls.sh" || true
fi
if [[ "${CYBERLUX_INSTALL_QUIET:-}" == "1" ]]; then
exit 0
fi

View File

@@ -30,18 +30,30 @@ echo ""
echo "CyberLux .onion URLs (http:// only — open in Tor Browser)"
echo "────────────────────────────────────────────────────────────"
ready=0
while IFS= read -r d || [[ -n "${d}" ]]; do
[[ -z "${d}" ]] && continue
f="/var/lib/tor/${d}/hostname"
host="$(read_host "${f}")"
if [[ -n "${host}" ]]; then
printf '%-28s http://%s\n' "${d}" "${host}"
((ready++)) || true
else
printf '%-28s (no hostname yet — %s)\n' "${d}" "${f}"
fi
done < "${TOR_DIRS}"
echo "────────────────────────────────────────────────────────────"
if [[ "${ready}" -eq 0 ]] && [[ -f "${REPO}/onion-urls.txt" ]]; then
echo ""
echo "If lines show (no hostname yet): wait for Tor, or sudo ls -la /var/lib/tor/"
echo "Could not read /var/lib/tor (permission). Last exported list:"
echo "────────────────────────────────────────────────────────────"
grep -vE '^#|^$' "${REPO}/onion-urls.txt" 2>/dev/null | head -120 || true
echo ""
echo "Refresh: sudo bash ${REPO}/scripts/export-onion-urls.sh"
elif [[ "${ready}" -eq 0 ]]; then
echo ""
echo "No hostnames resolved. Ensure Tor is running and keys exist under /var/lib/tor/,"
echo "or run: sudo bash ${REPO}/scripts/export-onion-urls.sh"
fi
echo ""

View File

@@ -6,7 +6,8 @@
* node scripts/onion-status.cjs
* npm run onions:status
*
* Reading /var/lib/tor/*/hostname usually requires sudo:
* Reading hostname files under /var/lib/tor (one directory per service) usually
* requires sudo:
* sudo node scripts/onion-status.cjs
*/
@@ -20,21 +21,46 @@ const REPO = path.join(__dirname, "..");
const jsonPath = path.join(__dirname, "onion-nodes.json");
const data = JSON.parse(fs.readFileSync(jsonPath, "utf8"));
/** Populated by export-onion-urls.sh — readable when /var/lib/tor is root-only. */
function readOnionFromExportFile(torDir) {
const exportPath = path.join(REPO, "onion-urls.txt");
let raw;
try {
raw = fs.readFileSync(exportPath, "utf8");
} catch {
return "";
}
const line = raw.split("\n").find((l) => l.trimStart().startsWith(torDir));
if (!line) return "";
const m = line.match(/https?:\/\/([a-z2-7]{56}\.onion)\b/i);
return m ? m[1] : "";
}
function readOnionHost(torDir) {
const f = path.join("/var/lib/tor", torDir, "hostname");
try {
return fs.readFileSync(f, "utf8").trim();
} catch (e) {
if (e.code === "EACCES" || e.code === "EPERM") {
return "(hostname file exists but not readable — try: sudo node scripts/onion-status.cjs)";
const fromFile = readOnionFromExportFile(torDir);
if (fromFile) return `${fromFile} (from onion-urls.txt)`;
return "(hostname not readable — run: sudo bash scripts/export-onion-urls.sh, then retry)";
}
if (e.code === "ENOENT") {
const fromFile = readOnionFromExportFile(torDir);
if (fromFile) return `${fromFile} (from onion-urls.txt)`;
return "(no hostname yet — is Tor running?)";
}
return `(${e.message})`;
}
}
function onionHttpUrl(onionField) {
const m = String(onionField).match(/([a-z2-7]{56}\.onion)/i);
if (m) return `http://${m[1]}`;
return onionField;
}
function checkLocalPort(port) {
return new Promise((resolve) => {
const req = http.request(
@@ -77,14 +103,14 @@ async function main() {
}
console.log("");
console.log("CyberLux — onion URLs (from /var/lib/tor/*/hostname) + loopback health");
console.log("CyberLux — onion URLs (from /var/lib/tor/<service>/hostname) + loopback health");
console.log("─".repeat(100));
let bad = 0;
for (const r of rows) {
const url =
r.onion.startsWith("(") || r.onion.includes("not readable")
? r.onion
: `http://${r.onion}`;
: onionHttpUrl(r.onion);
const local = `http://127.0.0.1:${r.port}/`;
const status =
r.httpCode === 0 ? "DOWN/timeout" : `HTTP ${r.httpCode}`;

View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Start CyberLux onion backend if 127.0.0.1:3000 is not already occupied.
# Used by the user systemd unit and @reboot cron fallback.
set -euo pipefail
REPO="/home/drjones/cyberlux"
LOG="${REPO}/logs/onion-runtime.log"
mkdir -p "${REPO}/logs"
if command -v ss >/dev/null 2>&1 && ss -ltn 'sport = :3000' | grep -q '127.0.0.1:3000'; then
echo "$(date -Is) cyberlux already listening on 127.0.0.1:3000" >>"${LOG}"
exit 0
fi
cd "${REPO}"
node scripts/generate-onion-config.cjs >>"${LOG}" 2>&1
exec npm run start:onion >>"${LOG}" 2>&1

View File

@@ -20,8 +20,9 @@ try {
run("TypeScript (tsc --noEmit)", "npx tsc --noEmit");
run(
"bash syntax (start.sh, install, backup, restore, systemd, health)",
"bash -n start.sh && bash -n scripts/install-tor-onion.sh && bash -n scripts/install-systemd.sh && bash -n scripts/health-check-stack.sh && bash -n scripts/list-onion-urls.sh && bash -n scripts/backup-onion-keys.sh && bash -n scripts/restore-onion-keys.sh",
"bash -n start.sh && bash -n scripts/install-tor-onion.sh && bash -n scripts/install-systemd.sh && bash -n scripts/health-check-stack.sh && bash -n scripts/list-onion-urls.sh && bash -n scripts/export-onion-urls.sh && bash -n scripts/diagnose-onion-stack.sh && bash -n scripts/backup-onion-keys.sh && bash -n scripts/restore-onion-keys.sh",
);
run("node syntax (onion-status.cjs)", "node --check scripts/onion-status.cjs");
run("Next.js production build", "npm run build");
console.log("\n✓ verify: all checks passed.\n");
} catch {

View File

@@ -242,6 +242,11 @@ else
sudo bash "${BACKUP_SCRIPT}" || echo "[!] Onion key backup refresh failed."
fi
if [[ -f "${REPO}/scripts/export-onion-urls.sh" ]]; then
echo "[*] Exporting live .onion list to onion-urls.txt (readable without /var/lib/tor access)…"
sudo bash "${REPO}/scripts/export-onion-urls.sh" || echo "[!] export-onion-urls.sh failed (URLs still in Tor; re-run with sudo if needed)."
fi
print_onion_banner
fi