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:
59
app/account/page.tsx
Normal file
59
app/account/page.tsx
Normal 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
79
app/api/messages/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 A–Z</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">
|
||||
<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="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>
|
||||
|
||||
220
app/market/product/[id]/page.tsx
Normal file
220
app/market/product/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
const res = await fetch("/api/messages?channel=global");
|
||||
const data = await res.json();
|
||||
if (data.ok && data.messages) {
|
||||
setMsgs(data.messages);
|
||||
}
|
||||
} catch {
|
||||
setReady(true);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
setReady(true);
|
||||
}, [storageKey, hydrated]);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
30
app/page.tsx
30
app/page.tsx
@@ -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 first‑of‑its‑kind cyberpunk‑luxury shopping experience.
|
||||
A first‑of‑its‑kind 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>
|
||||
|
||||
@@ -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>{" "}
|
||||
|
||||
@@ -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) {
|
||||
setIsProtected(false);
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage blocked (rare) — still allow progress overlay to complete
|
||||
if (readDdosVerified()) {
|
||||
setIsProtected(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
try {
|
||||
sessionStorage.setItem("ddos_verified", "true");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
persistDdosVerified();
|
||||
setIsProtected(false);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,152 +2,242 @@
|
||||
|
||||
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 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} 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="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 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-1 overflow-y-auto">
|
||||
{terminalLines.map((line, i) => (
|
||||
<div key={`${i}-${line.slice(0, 24)}`} className={line.includes("[ADMIN]") ? "font-bold text-red-500" : ""}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div className="animate-pulse">_</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="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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user