Harden onion boot flow and deepen site surfaces
Add persistent onion key backup and restore, improve startup resilience, and flesh out the major site verticals with richer navigation, search coverage, and operator documentation. Made-with: Cursor
This commit is contained in:
173
components/AccountCreation.tsx
Normal file
173
components/AccountCreation.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
const AccountCreation = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [recoveryPhrase, setRecoveryPhrase] = useState("");
|
||||
const [encryptionKey, setEncryptionKey] = useState("");
|
||||
const [generated, setGenerated] = useState(false);
|
||||
|
||||
const generateRecovery = () => {
|
||||
const phrases = [
|
||||
"phantom quantum glacier nexus",
|
||||
"crimson velvet paradox silence",
|
||||
"neon abyss whisper eclipse",
|
||||
"zero trace ghost protocol",
|
||||
];
|
||||
const randomPhrase = phrases[Math.floor(Math.random() * phrases.length)];
|
||||
setRecoveryPhrase(randomPhrase);
|
||||
setEncryptionKey(btoa(Date.now().toString()).slice(0, 16));
|
||||
setGenerated(true);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (step < 3) {
|
||||
setStep(step + 1);
|
||||
} else {
|
||||
alert("Identity provisioned. Keys derived locally — backup your recovery phrase offline.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass mx-auto max-w-2xl rounded-3xl border border-white/10 p-10">
|
||||
<h2 className="mb-8 font-orbitron text-4xl font-bold text-center">CREATE SHADOW IDENTITY</h2>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="mb-10 flex justify-between">
|
||||
{[1, 2, 3].map((s) => (
|
||||
<div key={s} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 text-lg font-bold ${step >= s
|
||||
? "border-neon-cyan bg-neon-cyan/20 text-neon-cyan"
|
||||
: "border-white/20 text-foreground/40"
|
||||
}`}
|
||||
>
|
||||
{s}
|
||||
</div>
|
||||
<div className="mt-2 text-sm">
|
||||
{s === 1 && "Credentials"}
|
||||
{s === 2 && "Recovery"}
|
||||
{s === 3 && "Encryption"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{step === 1 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block font-medium">Shadow Alias</label>
|
||||
<input
|
||||
type="text"
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent p-4"
|
||||
placeholder="e.g., Ghost_23"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-sm text-foreground/60">Never your real name. This alias is encrypted.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block font-medium">Zero‑Knowledge Password</label>
|
||||
<input
|
||||
type="password"
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent p-4"
|
||||
placeholder="••••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<p className="mt-2 text-sm text-foreground/60">We never store your password. It’s used to derive your encryption key.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block font-medium">Recovery Phrase</label>
|
||||
<div className="glass rounded-xl border border-neon-cyan/30 p-6 font-mono text-center text-lg">
|
||||
{generated ? recoveryPhrase : "Click generate to create"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-4 w-full rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple py-3 font-bold text-background"
|
||||
onClick={generateRecovery}
|
||||
>
|
||||
GENERATE RECOVERY PHRASE
|
||||
</button>
|
||||
<p className="mt-4 text-sm text-foreground/60">
|
||||
Write this down physically. It’s the only way to recover your account. We do not store it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block font-medium">Encryption Key</label>
|
||||
<div className="glass rounded-xl border border-neon-green/30 p-6 font-mono text-center text-lg">
|
||||
{encryptionKey || "Not generated"}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-foreground/60">
|
||||
This key encrypts all your data locally. It is derived from your password and never leaves your device.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gradient-to-r from-neon-cyan/10 to-neon-purple/10 p-6">
|
||||
<h3 className="font-bold">Identity Summary</h3>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>Alias</div>
|
||||
<div className="font-mono">{username || "—"}</div>
|
||||
<div>Recovery Phrase</div>
|
||||
<div className="font-mono truncate">{recoveryPhrase || "—"}</div>
|
||||
<div>Encryption</div>
|
||||
<div className="font-mono text-neon-green">AES‑256‑GCM</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="glass rounded-full px-8 py-3 font-medium disabled:opacity-30"
|
||||
onClick={() => setStep(step - 1)}
|
||||
disabled={step === 1}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-10 py-3 font-bold text-background"
|
||||
>
|
||||
{step === 3 ? "COMPLETE IDENTITY CREATION" : "CONTINUE →"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-8 text-center text-sm text-foreground/40">
|
||||
<p>Keys and aliases never leave this browser profile. Clear storage = identity loss.</p>
|
||||
<p className="mt-4 text-foreground/55">
|
||||
For a <strong className="text-neon-cyan/90">live stall session</strong> tied to wallet and activity feeds, use{" "}
|
||||
<Link href="/sign-up" className="text-neon-cyan underline hover:text-neon-purple">
|
||||
create account
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link href="/sign-in" className="text-neon-cyan underline hover:text-neon-purple">
|
||||
sign in
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountCreation;
|
||||
17
components/AppProviders.tsx
Normal file
17
components/AppProviders.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AccountProvider } from "@/contexts/AccountContext";
|
||||
import { CartProvider } from "@/contexts/CartContext";
|
||||
import { WalletProvider } from "@/contexts/WalletContext";
|
||||
|
||||
/** Client-only wrappers so wallet + persistent account work on every route. */
|
||||
export default function AppProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<WalletProvider>
|
||||
<AccountProvider>
|
||||
<CartProvider>{children}</CartProvider>
|
||||
</AccountProvider>
|
||||
</WalletProvider>
|
||||
);
|
||||
}
|
||||
156
components/ChatWidget.tsx
Normal file
156
components/ChatWidget.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const ChatWidget = () => {
|
||||
const [messages, setMessages] = useState([
|
||||
{ id: 1, sender: "System", text: "Welcome to the encrypted channel. All messages are end‑to‑end encrypted.", time: "12:00" },
|
||||
{ id: 2, sender: "Ghost", text: "Has anyone tested the new stimulant batch?", time: "12:05" },
|
||||
{ id: 3, sender: "Vendor_X", text: "Batch #8 passes all purity tests. Available for bulk.", time: "12:07" },
|
||||
{ id: 4, sender: "Anonymous", text: "Need a EU passport within 48 hours. DM if you can deliver.", time: "12:10" },
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
/** Set after mount so SSR HTML matches first client paint (no Math.random in initial state). */
|
||||
const [encryptionKey, setEncryptionKey] = useState("");
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setEncryptionKey("session_" + Math.random().toString(36).substring(2, 9));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!input.trim()) return;
|
||||
const newMessage = {
|
||||
id: messages.length + 1,
|
||||
sender: "You",
|
||||
text: input,
|
||||
time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
};
|
||||
setMessages([...messages, newMessage]);
|
||||
setInput("");
|
||||
|
||||
// Simulate a reply after a delay
|
||||
setTimeout(() => {
|
||||
const replies = [
|
||||
"Received. Encryption verified.",
|
||||
"I can help with that. Check your vault.",
|
||||
"New drop incoming in 24 hours.",
|
||||
"Your request has been logged.",
|
||||
];
|
||||
const randomReply = replies[Math.floor(Math.random() * replies.length)];
|
||||
const replyMessage = {
|
||||
id: messages.length + 2,
|
||||
sender: "System",
|
||||
text: randomReply,
|
||||
time: new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
};
|
||||
setMessages(prev => [...prev, replyMessage]);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleEncrypt = () => {
|
||||
alert(`Session key: ${encryptionKey}\nAll messages are encrypted with this key.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass rounded-3xl border border-white/10 p-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-orbitron text-3xl font-bold">ENCRYPTED CHAT</h2>
|
||||
<p className="text-foreground/60">End‑to‑end encrypted messaging. No logs, no traces.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEncrypt}
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-3 font-bold text-background"
|
||||
>
|
||||
🔐 SHOW SESSION KEY
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat messages */}
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="glass mb-6 h-96 overflow-y-auto rounded-2xl border border-white/10 p-6"
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`mb-4 ${msg.sender === "You" ? "text-right" : ""}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className={`inline-block rounded-full px-3 py-1 text-xs font-medium ${msg.sender === "You"
|
||||
? "bg-neon-cyan/20 text-neon-cyan"
|
||||
: msg.sender === "System"
|
||||
? "bg-neon-purple/20 text-neon-purple"
|
||||
: "bg-white/10"
|
||||
}`}>
|
||||
{msg.sender}
|
||||
</span>
|
||||
<span className="text-xs text-foreground/40">{msg.time}</span>
|
||||
</div>
|
||||
<div className={`glass inline-block max-w-[80%] rounded-2xl px-4 py-3 ${msg.sender === "You"
|
||||
? "bg-gradient-to-r from-neon-cyan/20 to-neon-cyan/10"
|
||||
: "bg-white/5"
|
||||
}`}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
className="glass flex-1 rounded-2xl border border-white/10 bg-transparent px-6 py-4"
|
||||
placeholder="Type your encrypted message..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-8 py-4 font-bold text-background"
|
||||
>
|
||||
SEND
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat features */}
|
||||
<div className="mt-8 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div className="glass rounded-2xl border border-white/10 p-4 text-center">
|
||||
<div className="text-2xl">🔒</div>
|
||||
<div className="mt-2 text-sm font-bold">AES‑256‑GCM</div>
|
||||
<div className="text-xs text-foreground/60">Encryption</div>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-4 text-center">
|
||||
<div className="text-2xl">⚡</div>
|
||||
<div className="mt-2 text-sm font-bold">Zero‑Knowledge</div>
|
||||
<div className="text-xs text-foreground/60">No server storage</div>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-4 text-center">
|
||||
<div className="text-2xl">🌐</div>
|
||||
<div className="mt-2 text-sm font-bold">WebRTC</div>
|
||||
<div className="text-xs text-foreground/60">P2P possible</div>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-4 text-center">
|
||||
<div className="text-2xl">🕶️</div>
|
||||
<div className="mt-2 text-sm font-bold">Self‑Destruct</div>
|
||||
<div className="text-xs text-foreground/60">Messages expire</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-xs text-foreground/40">
|
||||
<p>Ephemeral client-side queue — nothing leaves this browser profile until a real transport is configured.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWidget;
|
||||
562
components/CheckoutFlow.tsx
Normal file
562
components/CheckoutFlow.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { useWallet } from "@/contexts/WalletContext";
|
||||
import { getMerchantBtcAddress, isMerchantBtcConfigured } from "@/lib/merchantBtc";
|
||||
import { estimateUsdForCryptoAmount } from "@/lib/shopFx";
|
||||
|
||||
const ENCRYPTION_BTC_FEE: Record<"standard" | "enhanced" | "quantum", number> = {
|
||||
standard: 0,
|
||||
enhanced: 0.001,
|
||||
quantum: 0.005,
|
||||
};
|
||||
|
||||
const CheckoutFlow = () => {
|
||||
const { lines, setLineQty, removeLine, clearCart, hydrated: cartHydrated } = useCart();
|
||||
const [step, setStep] = useState(1);
|
||||
const [paymentMethod, setPaymentMethod] = useState<"crypto" | "guest" | "card">("crypto");
|
||||
const [cryptoType, setCryptoType] = useState<"BTC" | "ETH" | "XMR">("BTC");
|
||||
const [guestEmail, setGuestEmail] = useState("");
|
||||
const [encryptionLevel, setEncryptionLevel] = useState<"standard" | "enhanced" | "quantum">("enhanced");
|
||||
const [btcUsd, setBtcUsd] = useState<number | null>(null);
|
||||
const [txidInput, setTxidInput] = useState("");
|
||||
const [verifyMsg, setVerifyMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
const [verifyBusy, setVerifyBusy] = useState(false);
|
||||
const [payError, setPayError] = useState<string | null>(null);
|
||||
const [orderComplete, setOrderComplete] = useState(false);
|
||||
|
||||
const { usdStoreCredit, spendUsdStoreCredit, verifyBtcDeposit } = useWallet();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
||||
);
|
||||
const j = (await res.json()) as { bitcoin?: { usd?: number } };
|
||||
const p = j.bitcoin?.usd;
|
||||
if (!cancelled && p && Number.isFinite(p)) setBtcUsd(p);
|
||||
} catch {
|
||||
if (!cancelled) setBtcUsd(96000);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const spotBtc = btcUsd ?? 96000;
|
||||
|
||||
const subtotalUsd = useMemo(
|
||||
() =>
|
||||
Math.round(
|
||||
lines.reduce(
|
||||
(sum, l) => sum + estimateUsdForCryptoAmount(l.price, l.currency, spotBtc) * l.qty,
|
||||
0,
|
||||
) * 100,
|
||||
) / 100,
|
||||
[lines, spotBtc],
|
||||
);
|
||||
|
||||
const encryptionFeeUsd = useMemo(() => {
|
||||
const rate = btcUsd ?? 96000;
|
||||
return Math.round(ENCRYPTION_BTC_FEE[encryptionLevel] * rate * 100) / 100;
|
||||
}, [encryptionLevel, btcUsd]);
|
||||
|
||||
const orderTotalUsd = useMemo(
|
||||
() => Math.round((subtotalUsd + encryptionFeeUsd) * 100) / 100,
|
||||
[subtotalUsd, encryptionFeeUsd],
|
||||
);
|
||||
|
||||
const orderTotalBtc = useMemo(() => {
|
||||
const r = btcUsd && btcUsd > 0 ? btcUsd : spotBtc;
|
||||
if (!r || r <= 0) return null;
|
||||
return Math.round((orderTotalUsd / r) * 1e8) / 1e8;
|
||||
}, [orderTotalUsd, btcUsd, spotBtc]);
|
||||
|
||||
const merchantAddr = getMerchantBtcAddress();
|
||||
const merchantReady = isMerchantBtcConfigured();
|
||||
|
||||
const btcPayUri = useMemo(() => {
|
||||
if (!merchantReady || orderTotalBtc == null) return "";
|
||||
return `bitcoin:${merchantAddr}?amount=${orderTotalBtc}`;
|
||||
}, [merchantReady, merchantAddr, orderTotalBtc]);
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
setVerifyMsg(null);
|
||||
setVerifyBusy(true);
|
||||
try {
|
||||
const result = await verifyBtcDeposit(txidInput.trim());
|
||||
if (result.ok) {
|
||||
setVerifyMsg({
|
||||
kind: "ok",
|
||||
text: `Confirmed. Added $${result.creditedUsd?.toFixed(2) ?? "0.00"} USD to your spendable balance.`,
|
||||
});
|
||||
setTxidInput("");
|
||||
} else {
|
||||
setVerifyMsg({ kind: "err", text: result.error || "Verification failed" });
|
||||
}
|
||||
} finally {
|
||||
setVerifyBusy(false);
|
||||
}
|
||||
}, [txidInput, verifyBtcDeposit]);
|
||||
|
||||
const tryCompleteOrder = () => {
|
||||
setPayError(null);
|
||||
if (paymentMethod === "crypto") {
|
||||
if (!spendUsdStoreCredit(orderTotalUsd)) {
|
||||
setPayError(
|
||||
`Insufficient USD balance. You need $${orderTotalUsd.toFixed(2)}. Send Bitcoin to the address below, wait for at least one confirmation, then paste the transaction ID and click Verify.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setOrderComplete(true);
|
||||
clearCart();
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ number: 1, title: "Cart", description: "Review items" },
|
||||
{ number: 2, title: "Payment", description: "Choose method" },
|
||||
{ number: 3, title: "Encryption", description: "Select privacy level" },
|
||||
{ number: 4, title: "Confirmation", description: "Complete ritual" },
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
if (step === 1 && lines.length === 0) return;
|
||||
if (step < 4) setStep(step + 1);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (step > 1) setStep(step - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass mx-auto max-w-4xl rounded-3xl border border-white/10 p-8 md:p-12">
|
||||
<div className="mb-12">
|
||||
<div className="flex justify-between">
|
||||
{steps.map((s) => (
|
||||
<div key={s.number} className="flex flex-col items-center">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 text-lg font-bold ${step >= s.number
|
||||
? "border-neon-cyan bg-neon-cyan/20 text-neon-cyan"
|
||||
: "border-white/20 text-foreground/40"
|
||||
}`}
|
||||
>
|
||||
{s.number}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<div className="text-sm font-medium">{s.title}</div>
|
||||
<div className="text-xs text-foreground/50">{s.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative -top-6 -z-10 h-1 bg-white/10">
|
||||
<div
|
||||
className="h-1 bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500"
|
||||
style={{ width: `${((step - 1) / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h3 className="mb-6 font-orbitron text-2xl font-bold">YOUR CART</h3>
|
||||
{!cartHydrated ? (
|
||||
<p className="text-sm text-foreground/50">Loading cart…</p>
|
||||
) : lines.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-white/15 p-10 text-center">
|
||||
<p className="text-foreground/70">Your cart is empty.</p>
|
||||
<p className="mt-2 text-sm text-foreground/50">Add SKUs from the hub or candy market.</p>
|
||||
<Link
|
||||
href="/market"
|
||||
className="mt-6 inline-block rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-8 py-3 text-sm font-bold text-background"
|
||||
>
|
||||
Browse catalog
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{lines.map((item) => {
|
||||
const unitUsd = estimateUsdForCryptoAmount(item.price, item.currency, spotBtc);
|
||||
const lineUsd = Math.round(unitUsd * item.qty * 100) / 100;
|
||||
return (
|
||||
<div
|
||||
key={item.productId}
|
||||
className="flex flex-col gap-4 rounded-xl bg-white/5 p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="mt-1 font-mono text-xs text-foreground/50">
|
||||
{item.price} {item.currency} each · ≈ ${unitUsd.toFixed(2)} USD @ spot
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-foreground/50">Qty</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/15 px-2 py-0.5 text-sm hover:bg-white/10"
|
||||
onClick={() => setLineQty(item.productId, item.qty - 1)}
|
||||
aria-label="Decrease quantity"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center font-mono text-sm">{item.qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-white/15 px-2 py-0.5 text-sm hover:bg-white/10"
|
||||
onClick={() => setLineQty(item.productId, item.qty + 1)}
|
||||
aria-label="Increase quantity"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-xs text-red-400 hover:underline"
|
||||
onClick={() => removeLine(item.productId)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right sm:shrink-0">
|
||||
<div className="font-orbitron text-xl text-neon-cyan">${lineUsd.toLocaleString()}</div>
|
||||
<div className="text-xs text-foreground/50">USD est.</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{lines.length > 0 ? (
|
||||
<div className="mt-8 flex justify-between border-t border-white/10 pt-6 text-xl">
|
||||
<span>Subtotal (USD est.)</span>
|
||||
<span className="font-orbitron font-bold text-neon-cyan">
|
||||
${subtotalUsd.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h3 className="mb-6 font-orbitron text-2xl font-bold">SELECT PAYMENT METHOD</h3>
|
||||
<div className="mb-6 rounded-xl border border-neon-green/30 bg-neon-green/5 px-4 py-3 text-sm">
|
||||
<span className="font-bold text-neon-green">USD balance: </span>
|
||||
<span className="font-orbitron">${usdStoreCredit.toFixed(2)}</span>
|
||||
<span className="text-foreground/60">
|
||||
{" "}
|
||||
— use after Bitcoin deposit is verified (on-chain).
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
className={`glass rounded-2xl border p-6 text-center transition-all ${paymentMethod === "crypto" ? "border-neon-cyan ring-2 ring-neon-cyan/30" : "border-white/10"
|
||||
}`}
|
||||
onClick={() => setPaymentMethod("crypto")}
|
||||
>
|
||||
<div className="mb-4 text-4xl">₿</div>
|
||||
<div className="font-bold">Cryptocurrency</div>
|
||||
<div className="mt-2 text-sm text-foreground/60">Bitcoin checkout balance</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`glass rounded-2xl border p-6 text-center transition-all ${paymentMethod === "guest" ? "border-neon-cyan ring-2 ring-neon-cyan/30" : "border-white/10"
|
||||
}`}
|
||||
onClick={() => setPaymentMethod("guest")}
|
||||
>
|
||||
<div className="mb-4 text-4xl">👤</div>
|
||||
<div className="font-bold">Guest Checkout</div>
|
||||
<div className="mt-2 text-sm text-foreground/60">Demo — no USD charge</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`glass rounded-2xl border p-6 text-center transition-all ${paymentMethod === "card" ? "border-neon-cyan ring-2 ring-neon-cyan/30" : "border-white/10"
|
||||
}`}
|
||||
onClick={() => setPaymentMethod("card")}
|
||||
>
|
||||
<div className="mb-4 text-4xl">💳</div>
|
||||
<div className="font-bold">Card (Secure)</div>
|
||||
<div className="mt-2 text-sm text-foreground/60">Demo — no USD charge</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paymentMethod === "crypto" && (
|
||||
<div className="mt-8">
|
||||
<label className="mb-2 block font-medium">Select Cryptocurrency</label>
|
||||
<div className="flex gap-4">
|
||||
{(["BTC", "ETH", "XMR"] as const).map((coin) => (
|
||||
<button
|
||||
key={coin}
|
||||
type="button"
|
||||
className={`glass flex-1 rounded-xl border py-4 ${cryptoType === coin ? "border-neon-cyan bg-neon-cyan/10" : "border-white/10"
|
||||
}`}
|
||||
onClick={() => setCryptoType(coin)}
|
||||
>
|
||||
<div className="text-2xl">{coin === "BTC" ? "₿" : coin === "ETH" ? "Ξ" : "⏣"}</div>
|
||||
<div className="font-bold">{coin}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{cryptoType !== "BTC" && (
|
||||
<p className="mt-4 rounded-lg bg-white/5 p-4 text-sm text-foreground/70">
|
||||
Deposits that add USD balance use <strong>Bitcoin</strong> only. Switch to BTC to see the
|
||||
deposit address. (ETH/XMR shown for display.)
|
||||
</p>
|
||||
)}
|
||||
{cryptoType === "BTC" && (
|
||||
<div className="mt-6 space-y-4 rounded-xl bg-white/5 p-4">
|
||||
<div className="text-sm text-foreground/60">Estimated order total after encryption step</div>
|
||||
<div className="font-orbitron text-2xl text-neon-cyan">
|
||||
${orderTotalUsd.toLocaleString()} USD
|
||||
{orderTotalBtc != null && btcUsd != null && (
|
||||
<span className="ml-3 text-lg text-foreground/70">
|
||||
(≈ {orderTotalBtc} BTC @ ${btcUsd.toLocaleString()}/BTC)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-foreground/50">
|
||||
Final total includes the encryption add-on you pick in the next step. Fund your balance
|
||||
with <strong>any amount</strong> of BTC; verified value is credited in USD at spot rate.
|
||||
You can pay this order once your balance covers the total.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentMethod === "guest" && (
|
||||
<div className="mt-8">
|
||||
<label className="mb-2 block font-medium">Email for receipt (optional)</label>
|
||||
<input
|
||||
type="email"
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent px-4 py-3"
|
||||
placeholder="anonymous@example.com"
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
/>
|
||||
<p className="mt-2 text-sm text-foreground/60">
|
||||
Guest checkout is a demo and does not charge your USD balance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-10 border-t border-white/10 pt-8">
|
||||
<h4 className="mb-4 font-orbitron text-lg font-bold text-neon-cyan">Bitcoin deposit (all checkouts)</h4>
|
||||
{!merchantReady ? (
|
||||
<p className="text-sm text-amber-300/90">
|
||||
Set <code className="rounded bg-white/10 px-1">NEXT_PUBLIC_MERCHANT_BTC_ADDRESS</code> and{" "}
|
||||
<code className="rounded bg-white/10 px-1">MERCHANT_BTC_ADDRESS</code> in{" "}
|
||||
<code className="rounded bg-white/10 px-1">.env.local</code>, then restart the app.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3 text-sm text-foreground/70">
|
||||
Send Bitcoin to this address. After the network confirms your payment (at least one
|
||||
block), paste the transaction ID to add the equivalent USD to your balance.
|
||||
</p>
|
||||
<div className="rounded-xl border border-white/10 bg-black/40 p-4 font-mono text-sm break-all">
|
||||
{merchantAddr}
|
||||
</div>
|
||||
{btcPayUri && (
|
||||
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-start">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(btcPayUri)}`}
|
||||
alt="Bitcoin payment QR"
|
||||
width={160}
|
||||
height={160}
|
||||
className="rounded-lg border border-white/10"
|
||||
/>
|
||||
<div className="text-xs text-foreground/50">
|
||||
QR encodes a suggested payment URI for the current cart estimate (you may send any
|
||||
amount; all verified BTC to this address becomes USD credit).
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 space-y-2">
|
||||
<label className="text-sm font-medium">Transaction ID (after confirmed)</label>
|
||||
<input
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm"
|
||||
placeholder="64-character txid"
|
||||
value={txidInput}
|
||||
onChange={(e) => setTxidInput(e.target.value.replace(/\s+/g, ""))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={verifyBusy}
|
||||
onClick={() => void handleVerify()}
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-2 text-sm font-bold text-background disabled:opacity-40"
|
||||
>
|
||||
{verifyBusy ? "Verifying…" : "Verify & add USD credit"}
|
||||
</button>
|
||||
{verifyMsg && (
|
||||
<p
|
||||
className={`text-sm ${verifyMsg.kind === "ok" ? "text-neon-green" : "text-red-400"}`}
|
||||
>
|
||||
{verifyMsg.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h3 className="mb-6 font-orbitron text-2xl font-bold">ENCRYPTION LEVEL</h3>
|
||||
<p className="mb-8 text-foreground/70">
|
||||
Choose how securely your data is protected. Higher levels add to your order total in USD.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ level: "standard" as const, name: "Stealth Encryption", desc: "Military‑grade encryption, basic anonymity", icon: "🕵️" },
|
||||
{ level: "enhanced" as const, name: "Ghost Protocol", desc: "Post‑quantum cryptography, zero‑trace", icon: "👻" },
|
||||
{ level: "quantum" as const, name: "Zero‑Trace", desc: "Experimental, quantum‑entangled proofs", icon: "⚛️" },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.level}
|
||||
type="button"
|
||||
className={`glass flex w-full items-center justify-between rounded-2xl border p-6 text-left ${encryptionLevel === opt.level ? "border-neon-cyan ring-2 ring-neon-cyan/30" : "border-white/10"
|
||||
}`}
|
||||
onClick={() => setEncryptionLevel(opt.level)}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl">{opt.icon}</span>
|
||||
<div>
|
||||
<div className="font-bold">{opt.name}</div>
|
||||
<div className="text-sm text-foreground/60">{opt.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right font-orbitron text-xl font-bold text-neon-cyan">
|
||||
{opt.level === "standard"
|
||||
? "FREE"
|
||||
: `+$${(Math.round(ENCRYPTION_BTC_FEE[opt.level] * (btcUsd ?? 96000) * 100) / 100).toFixed(2)}`}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 rounded-xl bg-gradient-to-r from-neon-cyan/10 to-neon-purple/10 p-6">
|
||||
<div className="font-orbitron font-bold">Order total so far</div>
|
||||
<div className="mt-2 text-2xl text-neon-cyan">
|
||||
${orderTotalUsd.toLocaleString()} USD
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && !orderComplete && (
|
||||
<div className="text-center">
|
||||
<h3 className="mb-4 font-orbitron text-3xl font-bold">COMPLETE PAYMENT</h3>
|
||||
<div className="glass mx-auto mb-8 max-w-md rounded-2xl border border-white/10 p-6 text-left">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-foreground/60">Total due</span>
|
||||
<span className="font-orbitron font-bold text-neon-cyan">
|
||||
${orderTotalUsd.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-sm">
|
||||
<span className="text-foreground/60">Your USD balance</span>
|
||||
<span className="font-orbitron">${usdStoreCredit.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between text-sm">
|
||||
<span className="text-foreground/60">Payment path</span>
|
||||
<span className="font-bold">
|
||||
{paymentMethod === "crypto" ? "Crypto (USD balance)" : "Demo (guest/card)"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{payError && <p className="mx-auto mb-6 max-w-xl text-sm text-red-400">{payError}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={tryCompleteOrder}
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-10 py-3 font-bold text-background"
|
||||
>
|
||||
{paymentMethod === "crypto" ? "Charge my USD balance & complete" : "Complete demo order"}
|
||||
</button>
|
||||
{paymentMethod === "crypto" && (
|
||||
<p className="mx-auto mt-6 max-w-xl text-xs text-foreground/50">
|
||||
Crypto checkout pulls from your verified USD balance only. Send BTC on the payment step and
|
||||
verify the txid so your balance matches the total above.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && orderComplete && (
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<div className="inline-flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple">
|
||||
<span className="text-4xl">✨</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-4 font-orbitron text-3xl font-bold">RITUAL COMPLETE</h3>
|
||||
<p className="mx-auto max-w-2xl text-foreground/70">
|
||||
Your order is now encrypted with {encryptionLevel} protection. A unique digital artwork receipt
|
||||
has been generated and stored in your vault.
|
||||
</p>
|
||||
<div className="my-10">
|
||||
<div className="glass mx-auto max-w-md rounded-2xl border border-white/10 p-6">
|
||||
<div className="font-orbitron text-2xl font-bold text-neon-cyan">ORDER #CYBR‑9A2F‑B8E1</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-left text-foreground/60">Amount</div>
|
||||
<div className="text-right font-bold">${orderTotalUsd.toLocaleString()} USD</div>
|
||||
<div className="text-left text-foreground/60">Privacy</div>
|
||||
<div className="text-right font-bold">{encryptionLevel.toUpperCase()}</div>
|
||||
<div className="text-left text-foreground/60">Payment</div>
|
||||
<div className="truncate text-right font-mono text-neon-green">
|
||||
{paymentMethod === "crypto" ? "USD balance (BTC-funded)" : "Demo guest/card"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground/50">
|
||||
Your digital receipt is available in your vault. Thank you for choosing CyberLux.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="glass rounded-full px-8 py-3 font-medium disabled:opacity-30"
|
||||
onClick={handlePrev}
|
||||
disabled={step === 1 || (step === 4 && orderComplete)}
|
||||
>
|
||||
← Previous
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
{step < 4 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={step === 1 && lines.length === 0}
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-8 py-3 font-bold text-background disabled:opacity-40"
|
||||
onClick={handleNext}
|
||||
>
|
||||
Continue →
|
||||
</button>
|
||||
) : orderComplete ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-gradient-to-r from-neon-green to-neon-cyan px-10 py-3 font-bold text-background"
|
||||
>
|
||||
ENTER THE VAULT
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckoutFlow;
|
||||
100
components/DDoSProtection.tsx
Normal file
100
components/DDoSProtection.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
setRayId(Math.random().toString(36).substring(2, 10).toUpperCase());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
return 100;
|
||||
}
|
||||
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);
|
||||
|
||||
const statusUpdates = [
|
||||
"Checking your browser…",
|
||||
"Verifying client challenge…",
|
||||
"Analyzing request signature…",
|
||||
"Negotiating TLS + Tor circuit…",
|
||||
"Establishing encrypted tunnel…",
|
||||
"Access granted.",
|
||||
];
|
||||
|
||||
let statusIndex = 0;
|
||||
const statusInterval = setInterval(() => {
|
||||
if (statusIndex < statusUpdates.length - 1) {
|
||||
statusIndex++;
|
||||
setStatus(statusUpdates[statusIndex]);
|
||||
} else {
|
||||
clearInterval(statusInterval);
|
||||
}
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearInterval(statusInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex flex-col items-center justify-center bg-black p-8 font-mono text-[#00ff41]"
|
||||
style={{ backgroundColor: "#000000", color: "#00ff41" }}
|
||||
>
|
||||
<div
|
||||
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">
|
||||
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="h-full bg-[#00ff41] transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-[10px] uppercase tracking-widest">
|
||||
<span>{status}</span>
|
||||
<span>{Math.floor(progress)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-[8px] opacity-20 leading-relaxed">
|
||||
Ray ID: {rayId || "—"}
|
||||
<br />
|
||||
Performance & Security by ShadowGuard™
|
||||
</div>
|
||||
</div>
|
||||
<p className="pointer-events-none fixed bottom-6 left-0 right-0 px-4 text-center text-2xl font-black text-red-500 sm:text-3xl md:text-4xl">
|
||||
This site is fake
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
components/EncryptionDemo.tsx
Normal file
101
components/EncryptionDemo.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
const EncryptionDemo = () => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [encrypted, setEncrypted] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
|
||||
const mockEncrypt = (text: string) => {
|
||||
if (!text.trim()) return "";
|
||||
// Simulate encryption by reversing and adding salt
|
||||
const salt = "⚡🔐";
|
||||
const reversed = text.split("").reverse().join("");
|
||||
return `encrypted_${btoa(reversed)}_${salt}`;
|
||||
};
|
||||
|
||||
const handleEncrypt = () => {
|
||||
const result = mockEncrypt(message);
|
||||
setEncrypted(result);
|
||||
setKey(`key_${Date.now().toString(16)}`);
|
||||
};
|
||||
|
||||
const handleDecrypt = () => {
|
||||
if (!encrypted) return;
|
||||
// Simulate decryption
|
||||
const parts = encrypted.split("_");
|
||||
if (parts.length < 2) return;
|
||||
const decoded = atob(parts[1]);
|
||||
const original = decoded.split("").reverse().join("");
|
||||
setMessage(original);
|
||||
setEncrypted("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass rounded-2xl border border-white/10 p-8">
|
||||
<h3 className="mb-6 font-orbitron text-2xl font-bold">ENCRYPTION DEMO</h3>
|
||||
<p className="mb-6 text-foreground/70">
|
||||
Experience client‑side encryption. Your message never leaves your browser.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Message to encrypt</label>
|
||||
<textarea
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent p-4"
|
||||
rows={3}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Enter a secret message..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
className="flex-1 rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple py-3 font-bold text-background"
|
||||
onClick={handleEncrypt}
|
||||
>
|
||||
🔐 Encrypt
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 rounded-full glass border border-neon-cyan/30 py-3 font-bold text-neon-cyan"
|
||||
onClick={handleDecrypt}
|
||||
disabled={!encrypted}
|
||||
>
|
||||
🔓 Decrypt
|
||||
</button>
|
||||
</div>
|
||||
{encrypted && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Encrypted payload</label>
|
||||
<div className="glass overflow-auto rounded-xl border border-neon-cyan/30 p-4 font-mono text-sm">
|
||||
{encrypted}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">Generated key</label>
|
||||
<div className="glass overflow-auto rounded-xl border border-neon-green/30 p-4 font-mono text-sm text-neon-green">
|
||||
{key}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gradient-to-r from-neon-cyan/10 to-neon-purple/10 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-2xl">🛡️</div>
|
||||
<div className="text-sm">
|
||||
Educational transform only — production wallets use audited libs and real key agreement.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="text-xs text-foreground/50">
|
||||
<p>
|
||||
All sensitive data on CyberLux is encrypted using AES‑256‑GCM. Private keys are never transmitted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EncryptionDemo;
|
||||
220
components/ForumBoard.tsx
Normal file
220
components/ForumBoard.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
const ForumBoard = () => {
|
||||
const [posts, setPosts] = useState([
|
||||
{
|
||||
id: 1,
|
||||
user: "GhostInTheShell",
|
||||
avatar: "👻",
|
||||
timestamp: "2 hours ago",
|
||||
content: "Has anyone tested the new neural stimulant? Need reports on side‑effects.",
|
||||
upvotes: 42,
|
||||
replies: 12,
|
||||
category: "Bio‑Enhancements",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: "ZeroCool",
|
||||
avatar: "🕵️",
|
||||
timestamp: "5 hours ago",
|
||||
content: "Quantum counterfeit notes batch #8 passes all validation tests. Available for bulk orders.",
|
||||
upvotes: 89,
|
||||
replies: 24,
|
||||
category: "Financial",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user: "CypherPunk",
|
||||
avatar: "🔐",
|
||||
timestamp: "1 day ago",
|
||||
content: "New darknet router vulnerability discovered. Patch your firmware immediately.",
|
||||
upvotes: 156,
|
||||
replies: 47,
|
||||
category: "Security",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
user: "Ethereal",
|
||||
avatar: "🌌",
|
||||
timestamp: "2 days ago",
|
||||
content: "Looking for reliable identity pack vendor with EU passports. DM with reputation score.",
|
||||
upvotes: 33,
|
||||
replies: 8,
|
||||
category: "Identity",
|
||||
},
|
||||
]);
|
||||
|
||||
const [newPost, setNewPost] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
|
||||
const categories = ["All", "Bio‑Enhancements", "Financial", "Security", "Identity", "Weapons", "Chemicals"];
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPost.trim()) return;
|
||||
const newPostObj = {
|
||||
id: posts.length + 1,
|
||||
user: "Anonymous",
|
||||
avatar: "🕶️",
|
||||
timestamp: "Just now",
|
||||
content: newPost,
|
||||
upvotes: 0,
|
||||
replies: 0,
|
||||
category: "Uncategorized",
|
||||
};
|
||||
setPosts([newPostObj, ...posts]);
|
||||
setNewPost("");
|
||||
};
|
||||
|
||||
const filteredPosts = selectedCategory === "All"
|
||||
? posts
|
||||
: posts.filter(p => p.category === selectedCategory);
|
||||
|
||||
return (
|
||||
<div className="glass rounded-3xl border border-white/10 p-8">
|
||||
<div className="mb-10">
|
||||
<h2 className="font-orbitron text-4xl font-bold">DARKNET FORUM</h2>
|
||||
<p className="mt-2 text-foreground/70">Encrypted, anonymous discussions. No logs, no traces.</p>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<div className="mb-8 flex flex-wrap gap-3">
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${selectedCategory === cat
|
||||
? "bg-gradient-to-r from-neon-cyan to-neon-purple text-background"
|
||||
: "glass border border-white/10"
|
||||
}`}
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* New post form */}
|
||||
<form onSubmit={handleSubmit} className="mb-10">
|
||||
<div className="glass rounded-2xl border border-white/10 p-6">
|
||||
<textarea
|
||||
className="w-full bg-transparent text-foreground placeholder-foreground/50 focus:outline-none"
|
||||
rows={3}
|
||||
placeholder="Type your encrypted message... (all posts are ephemeral)"
|
||||
value={newPost}
|
||||
onChange={(e) => setNewPost(e.target.value)}
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full bg-white/5 px-4 py-2 text-sm"
|
||||
>
|
||||
<span>🔒</span> Encrypt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full bg-white/5 px-4 py-2 text-sm"
|
||||
>
|
||||
<span>🕵️</span> Post Anonymously
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-3 font-bold text-background"
|
||||
>
|
||||
PUBLISH
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Posts list */}
|
||||
<div className="space-y-6">
|
||||
{filteredPosts.map((post) => (
|
||||
<div key={post.id} className="glass rounded-2xl border border-white/10 p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-gradient-to-br from-neon-cyan to-neon-purple text-2xl">
|
||||
{post.avatar}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-bold">{post.user}</span>
|
||||
<span className="rounded-full bg-white/5 px-3 py-1 text-xs">{post.category}</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground/60">{post.timestamp}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button className="flex items-center gap-2 text-sm text-foreground/60 hover:text-neon-cyan">
|
||||
<span>⬆</span> {post.upvotes}
|
||||
</button>
|
||||
<button className="flex items-center gap-2 text-sm text-foreground/60 hover:text-neon-cyan">
|
||||
<span>💬</span> {post.replies}
|
||||
</button>
|
||||
<button className="rounded-full bg-white/5 p-2 hover:bg-white/10">
|
||||
<span>🔗</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<p>{post.content}</p>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center gap-6 border-t border-white/10 pt-6">
|
||||
<button className="flex items-center gap-2 text-sm">
|
||||
<span>⬆</span> Upvote
|
||||
</button>
|
||||
<button className="flex items-center gap-2 text-sm">
|
||||
<span>⬇</span> Downvote
|
||||
</button>
|
||||
<button className="flex items-center gap-2 text-sm">
|
||||
<span>💬</span> Reply
|
||||
</button>
|
||||
<button className="flex items-center gap-2 text-sm">
|
||||
<span>🔐</span> Encrypt Reply
|
||||
</button>
|
||||
<button className="flex items-center gap-2 text-sm">
|
||||
<span>🚀</span> Boost
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Forum stats */}
|
||||
<div className="mt-10 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div className="glass rounded-2xl border border-white/10 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-neon-cyan">2.4k</div>
|
||||
<div className="text-sm text-foreground/60">Active Users</div>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-neon-purple">18.5k</div>
|
||||
<div className="text-sm text-foreground/60">Total Posts</div>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-neon-pink">94%</div>
|
||||
<div className="text-sm text-foreground/60">Encrypted</div>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-6 text-center">
|
||||
<div className="text-3xl font-bold text-neon-green">0</div>
|
||||
<div className="text-sm text-foreground/60">Data Leaks</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center text-xs text-foreground/40">
|
||||
<p>
|
||||
Preview board — for threads that persist in your browser, open the{" "}
|
||||
<Link href="/forum" className="text-neon-cyan hover:underline">
|
||||
full forum
|
||||
</Link>{" "}
|
||||
(dedicated .onion maps here too).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForumBoard;
|
||||
20
components/GlobalFakeSiteFooter.tsx
Normal file
20
components/GlobalFakeSiteFooter.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Single required disclosure for the build: appears after every route’s main content.
|
||||
*/
|
||||
export default function GlobalFakeSiteFooter() {
|
||||
return (
|
||||
<footer
|
||||
className="relative z-[1] border-t-4 border-red-600 bg-[#050505] px-4 py-12 shadow-[0_-12px_40px_rgba(0,0,0,0.85)]"
|
||||
role="contentinfo"
|
||||
aria-label="Fiction disclosure"
|
||||
>
|
||||
<div className="mx-auto max-w-5xl pb-16 sm:pb-20 md:pb-24">
|
||||
<p className="text-center text-4xl font-black leading-tight tracking-tight text-red-500 sm:text-5xl md:text-6xl lg:text-7xl">
|
||||
This site is fake
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
173
components/Hero.tsx
Normal file
173
components/Hero.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { SHOP_PRODUCT_COUNT } from "@/lib/shopCatalog";
|
||||
import { SHOP_SELLER_COUNT } from "@/lib/shopSellers";
|
||||
|
||||
const Hero = () => {
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [particles, setParticles] = useState<Array<{ x: number; y: number; size: number; speed: number }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMousePosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
|
||||
// Generate floating particles
|
||||
const particleCount = 30;
|
||||
const newParticles = [];
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
newParticles.push({
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
size: Math.random() * 3 + 1,
|
||||
speed: Math.random() * 0.5 + 0.2,
|
||||
});
|
||||
}
|
||||
setParticles(newParticles);
|
||||
|
||||
return () => window.removeEventListener("mousemove", handleMouseMove);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative min-h-screen overflow-hidden px-4 pt-32 md:pt-40">
|
||||
{/* Animated background particles */}
|
||||
<div className="absolute inset-0 -z-20">
|
||||
{particles.map((p, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute rounded-full bg-neon-cyan opacity-20"
|
||||
style={{
|
||||
left: `${p.x}vw`,
|
||||
top: `${p.y}vh`,
|
||||
width: `${p.size}px`,
|
||||
height: `${p.size}px`,
|
||||
animation: `float ${3 / p.speed}s ease-in-out infinite`,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* Glowing orbs */}
|
||||
<div
|
||||
className="absolute left-1/4 top-1/4 h-64 w-64 rounded-full bg-neon-purple opacity-10 blur-3xl"
|
||||
style={{
|
||||
transform: `translate(${mousePosition.x * 0.01}px, ${mousePosition.y * 0.01}px)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute right-1/4 bottom-1/4 h-80 w-80 rounded-full bg-neon-cyan opacity-10 blur-3xl"
|
||||
style={{
|
||||
transform: `translate(${-mousePosition.x * 0.01}px, ${-mousePosition.y * 0.01}px)`,
|
||||
}}
|
||||
/>
|
||||
{/* Grid lines */}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,transparent_49%,rgba(0,224,255,0.03)_50%,transparent_51%,transparent)] bg-[length:40px_40px]"></div>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(transparent_49%,rgba(0,224,255,0.03)_50%,transparent_51%,transparent)] bg-[length:40px_40px]"></div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span className="block text-transparent bg-gradient-to-r from-neon-cyan via-neon-purple to-neon-pink bg-clip-text">
|
||||
E‑COMMERCE
|
||||
</span>
|
||||
<span className="block">EXPERIENCE</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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div className="mb-10 flex flex-wrap gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-foreground/45">
|
||||
<Link href="#featured" className="rounded border border-white/10 px-3 py-1.5 hover:border-neon-cyan/40 hover:text-neon-cyan/90">
|
||||
Featured
|
||||
</Link>
|
||||
<Link href="#categories" className="rounded border border-white/10 px-3 py-1.5 hover:border-neon-cyan/40 hover:text-neon-cyan/90">
|
||||
Categories
|
||||
</Link>
|
||||
<Link href="/market" className="rounded border border-white/10 px-3 py-1.5 hover:border-neon-purple/40 hover:text-neon-purple/90">
|
||||
Full market
|
||||
</Link>
|
||||
<Link href="/forum" className="rounded border border-white/10 px-3 py-1.5 hover:border-[#ff9a3c]/40 hover:text-[#ffcb8a]">
|
||||
Forum
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="flex flex-wrap gap-4 md:gap-6">
|
||||
<Link
|
||||
href="/market"
|
||||
className="group relative overflow-hidden rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-8 py-4 font-bold text-background transition-all hover:scale-[1.02] hover:shadow-2xl hover:shadow-neon-cyan/40"
|
||||
>
|
||||
<span className="relative z-10">BROWSE CATALOG</span>
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-r from-neon-purple to-neon-cyan opacity-0 transition-opacity group-hover:opacity-100"></div>
|
||||
<div className="absolute -inset-1 -z-20 animate-pulse rounded-full bg-neon-cyan blur-md opacity-30"></div>
|
||||
</Link>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="group glass rounded-full border border-neon-green/35 px-8 py-4 font-bold text-neon-green transition-all hover:border-neon-green hover:bg-neon-green/10"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">₿</span>
|
||||
CHECKOUT & BALANCE
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sanctuary"
|
||||
className="group glass rounded-full border border-neon-cyan/30 px-7 py-4 text-sm font-bold text-neon-cyan transition-all hover:border-neon-cyan hover:bg-neon-cyan/10"
|
||||
>
|
||||
SANCTUARY
|
||||
</Link>
|
||||
<Link
|
||||
href="/drops"
|
||||
className="group glass rounded-full border border-neon-pink/25 px-7 py-4 text-sm font-medium text-foreground/85 transition-all hover:border-neon-pink/50"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-lg">🚀</span>
|
||||
DROPS
|
||||
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-neon-pink"></span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-20 grid grid-cols-2 gap-6 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" },
|
||||
].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="mt-2 text-sm text-foreground/60">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<div className="absolute bottom-10 left-1/2 -translate-x-1/2">
|
||||
<div className="h-10 w-px bg-gradient-to-b from-neon-cyan to-transparent"></div>
|
||||
<div className="mt-2 text-xs text-foreground/40">SCROLL TO UNCOVER</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hero;
|
||||
150
components/HubChatterPublicStrip.tsx
Normal file
150
components/HubChatterPublicStrip.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
CYBERLUX_ENTRY_COOKIE,
|
||||
parseCyberluxEntry,
|
||||
type CyberluxEntry,
|
||||
} from "@/lib/cyberluxEntry";
|
||||
import { formatRingLabel } from "@/lib/forumRings";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { getChatterSlice } from "@/lib/shopChatterThreads";
|
||||
|
||||
function readEntry(): CyberluxEntry {
|
||||
if (typeof document === "undefined") return "hub";
|
||||
const raw = `; ${document.cookie}`;
|
||||
const part = raw.split(`; ${CYBERLUX_ENTRY_COOKIE}=`);
|
||||
if (part.length !== 2) return "hub";
|
||||
const v = part.pop()?.split(";").shift();
|
||||
return parseCyberluxEntry(v);
|
||||
}
|
||||
|
||||
type HubSkin = { bar: string; accent: string };
|
||||
|
||||
const DEFAULT_HUB_SKIN: HubSkin = {
|
||||
bar: "border-zinc-700/40 bg-[#050608]/98 text-zinc-500",
|
||||
accent: "text-zinc-400",
|
||||
};
|
||||
|
||||
const SKINS: Partial<Record<CyberluxEntry, HubSkin>> = {
|
||||
hub: {
|
||||
bar: "border-cyan-500/25 bg-[#050608]/95 text-zinc-400",
|
||||
accent: "text-cyan-400/90",
|
||||
},
|
||||
forum: {
|
||||
bar: "border-[#5c4030] bg-[#090705]/98 text-[#b8a995]",
|
||||
accent: "text-[#ff9a3c]",
|
||||
},
|
||||
exchange: {
|
||||
bar: "border-[#4a3f36]/90 bg-[#0b0907]/98 text-[#a89888]",
|
||||
accent: "text-[#d4a574]",
|
||||
},
|
||||
wiki: {
|
||||
bar: "border-[#334155] bg-[#080c15]/98 text-[#94a3b8]",
|
||||
accent: "text-[#7dd3fc]",
|
||||
},
|
||||
market: {
|
||||
bar: "border-emerald-900/45 bg-[#030806]/98 text-[#9db0a4]",
|
||||
accent: "text-[#6ee7b7]",
|
||||
},
|
||||
barter: {
|
||||
bar: "border-rose-900/40 bg-[#0a0606]/98 text-[#c8a8a8]",
|
||||
accent: "text-[#fb7185]",
|
||||
},
|
||||
chatter: {
|
||||
bar: "border-violet-900/45 bg-[#070510]/98 text-[#a898c4]",
|
||||
accent: "text-[#c4b5fd]",
|
||||
},
|
||||
search: {
|
||||
bar: "border-slate-600/45 bg-[#050608]/98 text-[#94a3b8]",
|
||||
accent: "text-[#93c5fd]",
|
||||
},
|
||||
syndicate: {
|
||||
bar: "border-yellow-900/35 bg-[#0b0905]/98 text-[#b8a878]",
|
||||
accent: "text-[#facc15]",
|
||||
},
|
||||
w: {
|
||||
bar: "border-fuchsia-900/35 bg-[#08060a]/98 text-[#c4a8d4]",
|
||||
accent: "text-[#e879f9]",
|
||||
},
|
||||
};
|
||||
|
||||
function hubSkinFor(entry: CyberluxEntry): HubSkin {
|
||||
return SKINS[entry] ?? DEFAULT_HUB_SKIN;
|
||||
}
|
||||
|
||||
const PREVIEW_N = 3;
|
||||
|
||||
/**
|
||||
* Fixed strip above the disclosure bar: in-world “hub chatter” threads (forum links).
|
||||
* Hidden on `/chatter` where the full archive already fills the view.
|
||||
*/
|
||||
export default function HubChatterPublicStrip() {
|
||||
const pathname = usePathname();
|
||||
const { user, hydrated } = useAccount();
|
||||
const [entry, setEntry] = useState<CyberluxEntry>("hub");
|
||||
const threads = useMemo(() => getChatterSlice(1, PREVIEW_N).items, []);
|
||||
|
||||
useEffect(() => {
|
||||
setEntry(readEntry());
|
||||
}, []);
|
||||
|
||||
if (pathname?.startsWith("/chatter")) return null;
|
||||
|
||||
const skin = hubSkinFor(entry);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`pointer-events-auto fixed bottom-9 left-0 right-0 z-[9997] border-t px-3 py-2 shadow-[0_-6px_20px_rgba(0,0,0,0.4)] backdrop-blur-md sm:px-4 ${skin.bar}`}
|
||||
aria-label="Public hub chatter — recent threads about the main storefront"
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[9px] uppercase tracking-[0.22em]">
|
||||
<span className="rounded border border-white/10 px-1.5 py-0.5 text-[8px] text-[#94a3b8]/90">Public</span>
|
||||
<span className="opacity-50">·</span>
|
||||
<Link href="/chatter" className={`font-semibold ${skin.accent} hover:underline`}>
|
||||
Hub chatter
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-1">
|
||||
{threads.map((t, i) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className={`min-w-0 sm:max-w-[min(100%,280px)] ${i > 0 ? "hidden sm:block" : ""}`}
|
||||
>
|
||||
<Link
|
||||
href={`/forum/post/${t.id}`}
|
||||
className="group block font-mono text-[11px] leading-snug text-[#c8b8a4] hover:text-[#ffcb8a]"
|
||||
>
|
||||
<span className={`mr-1.5 text-[9px] uppercase tracking-wider ${skin.accent} opacity-85`}>
|
||||
{formatRingLabel(t.topicSlug)}
|
||||
</span>
|
||||
<span className="line-clamp-2 sm:line-clamp-1">{t.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-3 font-mono text-[10px] uppercase tracking-wider">
|
||||
<Link href="/chatter" className={`${skin.accent} hover:underline`}>
|
||||
Full archive →
|
||||
</Link>
|
||||
<Link href="/forum" className="text-[#6b6358] hover:text-[#b8a995]">
|
||||
Forum
|
||||
</Link>
|
||||
{hydrated ? (
|
||||
<Link
|
||||
href={user ? "/dashboard" : "/sign-in"}
|
||||
className="text-[#6b6358] hover:text-[#b8a995]"
|
||||
>
|
||||
{user ? "Dashboard" : "Sign in"}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
233
components/Navbar.tsx
Normal file
233
components/Navbar.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useWallet } from "@/contexts/WalletContext";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
|
||||
const Navbar = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { isConnected, address, connect, disconnect, 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: "🧅" },
|
||||
];
|
||||
|
||||
const handleWalletClick = () => {
|
||||
if (isConnected) {
|
||||
disconnect();
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
{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"
|
||||
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">
|
||||
{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>
|
||||
<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)"
|
||||
>
|
||||
<span className="text-xs text-foreground/60">USD</span>
|
||||
<span className="font-orbitron text-sm text-neon-cyan">${usdStoreCredit.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{hydrated ? (
|
||||
user ? (
|
||||
<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"
|
||||
>
|
||||
<span className="max-w-[8rem] truncate">{user.displayName}</span>
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Register
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
) : null}
|
||||
<button
|
||||
onClick={handleWalletClick}
|
||||
className={`flex items-center gap-2 rounded-full px-4 py-2 font-medium transition-all ${isConnected
|
||||
? "bg-gradient-to-r from-neon-green to-neon-cyan text-background"
|
||||
: "glow-border text-neon-cyan hover:bg-white/5"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{isConnected ? "🟢" : "⚡"}</span>
|
||||
{isConnected ? `${address?.slice(0, 6)}...${address?.slice(-4)}` : "Connect Wallet"}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full bg-white/5 p-2 hover:bg-white/10"
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{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">
|
||||
{cartReady ? (
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="relative flex-1 rounded-lg border border-white/15 py-2 text-center text-sm 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>
|
||||
) : 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"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
) : hydrated ? (
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="flex-1 rounded-lg border border-white/15 py-2 text-center text-sm 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) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
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"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
{item.label}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
100
components/OnionDisclosureBar.tsx
Normal file
100
components/OnionDisclosureBar.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
CYBERLUX_ENTRY_COOKIE,
|
||||
parseCyberluxEntry,
|
||||
type CyberluxEntry,
|
||||
} from "@/lib/cyberluxEntry";
|
||||
|
||||
function readEntryFromCookie(): CyberluxEntry {
|
||||
if (typeof document === "undefined") return "hub";
|
||||
const raw = `; ${document.cookie}`;
|
||||
const part = raw.split(`; ${CYBERLUX_ENTRY_COOKIE}=`);
|
||||
if (part.length !== 2) return "hub";
|
||||
const v = part.pop()?.split(";").shift();
|
||||
return parseCyberluxEntry(v);
|
||||
}
|
||||
|
||||
type Skin = { bar: string; label: string };
|
||||
|
||||
const DEFAULT_SKIN: Skin = {
|
||||
bar: "border-zinc-600/40 bg-black/90 text-zinc-500",
|
||||
label: "CYBERLUX NODE · Tor v3 · onion endpoint",
|
||||
};
|
||||
|
||||
const SKINS: Partial<Record<CyberluxEntry, Skin>> = {
|
||||
hub: {
|
||||
bar: "border-cyan-500/25 bg-black/85 text-zinc-400",
|
||||
label: "CYBERLUX · primary storefront · Tor v3 · connection verified",
|
||||
},
|
||||
forum: {
|
||||
bar: "border-[#ff7a18]/40 bg-[#0c0500]/95 text-[#ffc9a3]/70",
|
||||
label: "VOID AGGREGATE · forum relay · onion authenticated",
|
||||
},
|
||||
exchange: {
|
||||
bar: "border-amber-900/50 bg-[#0f0d0a]/95 text-[#c4b5a0]",
|
||||
label: "EXCHANGE MIRROR · classifieds host · encrypted circuit",
|
||||
},
|
||||
wiki: {
|
||||
bar: "border-[#3d5a80]/45 bg-[#0a0e14]/95 text-[#94a8c4]",
|
||||
label: "WIKI LAYER · directory mirror · read-only overlay",
|
||||
},
|
||||
market: {
|
||||
bar: "border-emerald-900/50 bg-[#040806]/95 text-[#9ca89a]",
|
||||
label: "MARKET NODE · vendor shelf · onion storefront",
|
||||
},
|
||||
barter: {
|
||||
bar: "border-rose-900/45 bg-[#0c0608]/95 text-[#d4a8b0]",
|
||||
label: "BARTER PIT · swap relay · Tor v3",
|
||||
},
|
||||
chatter: {
|
||||
bar: "border-violet-900/50 bg-[#08060c]/95 text-[#b8a9d4]",
|
||||
label: "CHATTER ARCHIVE · hub threads · public relay",
|
||||
},
|
||||
search: {
|
||||
bar: "border-slate-600/50 bg-[#06080a]/95 text-[#a8b4c4]",
|
||||
label: "SEARCH INDEX · catalog probe · encrypted circuit",
|
||||
},
|
||||
syndicate: {
|
||||
bar: "border-yellow-900/40 bg-[#0c0a06]/95 text-[#c9b896]",
|
||||
label: "SYNDICATE MAP · network atlas · node federation",
|
||||
},
|
||||
w: {
|
||||
bar: "border-fuchsia-900/40 bg-[#0a060c]/95 text-[#d8b8e8]",
|
||||
label: "SHADOW NODES · /w/[slug] · syndicate landing on /",
|
||||
},
|
||||
};
|
||||
|
||||
function skinFor(entry: CyberluxEntry): Skin {
|
||||
return (
|
||||
SKINS[entry] ?? {
|
||||
...DEFAULT_SKIN,
|
||||
label: `${entry.replace(/-/g, " ").toUpperCase()} · Tor v3 · onion relay`,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bottom strip keyed off proxy cookie (set from `X-Cyberlux-Node` behind nginx).
|
||||
* One Tor service = one visual personality for the disclosure line.
|
||||
*/
|
||||
export default function OnionDisclosureBar() {
|
||||
const [entry, setEntry] = useState<CyberluxEntry>("hub");
|
||||
|
||||
useEffect(() => {
|
||||
setEntry(readEntryFromCookie());
|
||||
}, []);
|
||||
|
||||
const skin = skinFor(entry);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none fixed bottom-0 left-0 right-0 z-[10000] border-t-2 py-1.5 text-center font-mono text-[10px] uppercase tracking-[0.2em] backdrop-blur-sm ${skin.bar}`}
|
||||
role="status"
|
||||
aria-label="Entry host"
|
||||
>
|
||||
{skin.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
components/ParticleBackground.tsx
Normal file
135
components/ParticleBackground.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const ParticleBackground = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas size
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
};
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
// Particle system
|
||||
const particles: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
radius: number;
|
||||
color: string;
|
||||
}> = [];
|
||||
|
||||
const colors = ["#00ffff", "#9d00ff", "#ff00ff", "#00ff9d"];
|
||||
|
||||
// Create particles
|
||||
for (let i = 0; i < 80; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.5,
|
||||
vy: (Math.random() - 0.5) * 0.5,
|
||||
radius: Math.random() * 2 + 0.5,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
});
|
||||
}
|
||||
|
||||
// Mouse interaction
|
||||
let mouseX = canvas.width / 2;
|
||||
let mouseY = canvas.height / 2;
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
mouseX = e.clientX;
|
||||
mouseY = e.clientY;
|
||||
};
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw connecting lines
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const dx = particles[i].x - particles[j].x;
|
||||
const dy = particles[i].y - particles[j].y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance < 150) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = `rgba(0, 224, 255, ${0.2 * (1 - distance / 150)})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.moveTo(particles[i].x, particles[i].y);
|
||||
ctx.lineTo(particles[j].x, particles[j].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update and draw particles
|
||||
particles.forEach((p) => {
|
||||
// Move particle
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
|
||||
// Bounce off edges
|
||||
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
|
||||
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
|
||||
|
||||
// Attract to mouse
|
||||
const dx = mouseX - p.x;
|
||||
const dy = mouseY - p.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance < 200) {
|
||||
p.vx += dx * 0.0001;
|
||||
p.vy += dy * 0.0001;
|
||||
}
|
||||
|
||||
// Limit velocity
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
|
||||
if (speed > 1) {
|
||||
p.vx = (p.vx / speed) * 1;
|
||||
p.vy = (p.vy / speed) * 1;
|
||||
}
|
||||
|
||||
// Draw particle
|
||||
ctx.beginPath();
|
||||
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.radius * 3);
|
||||
gradient.addColorStop(0, p.color);
|
||||
gradient.addColorStop(1, "transparent");
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
const animationId = requestAnimationFrame(animate);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 -z-10 h-full w-full"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParticleBackground;
|
||||
188
components/ProductCard.tsx
Normal file
188
components/ProductCard.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
|
||||
import { SHOP_PRODUCTS, type ShopProduct } from "@/lib/shopCatalog";
|
||||
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
|
||||
|
||||
interface ProductCardProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: "BTC" | "ETH" | "USD" | "XMR";
|
||||
category: string;
|
||||
imageUrl: string;
|
||||
sellerHandle: string;
|
||||
sellerSlug: string;
|
||||
limited?: boolean;
|
||||
glowColor?: string;
|
||||
/** When set, add-to-cart uses this row; otherwise looks up by `id` in SHOP_PRODUCTS */
|
||||
shopProduct?: ShopProduct;
|
||||
}
|
||||
|
||||
const ProductCard = ({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
currency,
|
||||
category,
|
||||
imageUrl,
|
||||
sellerHandle,
|
||||
sellerSlug,
|
||||
limited = false,
|
||||
glowColor: _glow = "neon-cyan",
|
||||
shopProduct,
|
||||
}: ProductCardProps) => {
|
||||
void _glow;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isFavorite, setIsFavorite] = useState(false);
|
||||
const [justAdded, setJustAdded] = useState(false);
|
||||
const { addToCart, hydrated: cartReady } = useCart();
|
||||
|
||||
const metrics = useMemo(() => getProductSocialMetrics(id), [id]);
|
||||
|
||||
const currencySymbols = {
|
||||
BTC: "₿",
|
||||
ETH: "Ξ",
|
||||
USD: "$",
|
||||
XMR: "⏣",
|
||||
};
|
||||
|
||||
const formatPrice = (p: number) => {
|
||||
if (currency === "BTC") return p.toFixed(6);
|
||||
if (currency === "ETH") return p.toFixed(4);
|
||||
if (currency === "XMR") return p.toFixed(3);
|
||||
return p.toFixed(2);
|
||||
};
|
||||
|
||||
const resolveProduct = (): ShopProduct | undefined => shopProduct ?? SHOP_PRODUCTS.find((x) => x.id === id);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
const p = resolveProduct();
|
||||
if (!p) return;
|
||||
addToCart(p, 1);
|
||||
setJustAdded(true);
|
||||
window.setTimeout(() => setJustAdded(false), 1800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/10 transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl hover:shadow-neon-cyan/10"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={`absolute -inset-1 -z-10 rounded-2xl opacity-0 blur-xl transition-opacity duration-500 ${
|
||||
isHovered ? "bg-neon-cyan/30 opacity-20" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
{limited && (
|
||||
<div className="absolute left-4 top-4 z-10">
|
||||
<div className="relative">
|
||||
<div className="animate-glow rounded-full bg-gradient-to-r from-neon-pink to-neon-purple px-3 py-1 text-xs font-bold text-background">
|
||||
LIMITED
|
||||
</div>
|
||||
<div className="absolute -inset-1 -z-10 animate-pulse rounded-full bg-neon-pink blur-md opacity-30"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-4 top-4 z-10 glass rounded-full p-2"
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
>
|
||||
<span className={`text-lg ${isFavorite ? "text-neon-pink" : "text-foreground/60"}`}>
|
||||
{isFavorite ? "❤️" : "🤍"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
unoptimized={imageUrl.startsWith("/")}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
|
||||
<div className="glass border-t border-white/10 p-6 backdrop-blur-sm">
|
||||
<div className="mb-3">
|
||||
<ProductSocialBlock metrics={metrics} compact />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="rounded-full bg-white/5 px-3 py-1 text-xs font-medium text-neon-cyan">{category}</span>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<span className="font-orbitron text-xl font-bold text-foreground md:text-2xl">
|
||||
{currencySymbols[currency]}
|
||||
{formatPrice(price)}
|
||||
</span>
|
||||
<span className="text-sm text-foreground/50">{currency}</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 font-orbitron text-xl font-bold">{name}</h3>
|
||||
<p className="mb-1 text-xs text-foreground/50">
|
||||
Seller:{" "}
|
||||
<Link href={`/vendor/${sellerSlug}`} className="text-neon-cyan hover:text-neon-purple hover:underline">
|
||||
{sellerHandle}
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mb-4 line-clamp-3 text-sm text-foreground/70">{description}</p>
|
||||
|
||||
<div className="mb-4 space-y-2 border-t border-white/10 pt-4">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-foreground/35">Verified chatter</p>
|
||||
{metrics.testimonials.slice(0, 1).map((t, i) => (
|
||||
<p key={i} className="text-xs italic leading-relaxed text-foreground/55">
|
||||
“{t.text}”
|
||||
<span className="mt-1 block font-mono text-[10px] not-italic text-neon-cyan/70">
|
||||
— @{t.handle} · ★{t.rating} · {t.daysAgo}d ago
|
||||
</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddToCart}
|
||||
disabled={!cartReady || !resolveProduct()}
|
||||
className="flex-1 rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple py-3 text-sm font-bold text-background transition-transform enabled:hover:scale-[1.02] disabled:opacity-50"
|
||||
>
|
||||
{justAdded ? "ADDED ✓" : "ADD TO CART"}
|
||||
</button>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="glass flex items-center justify-center rounded-full p-3 transition-colors hover:bg-white/10"
|
||||
title="Checkout"
|
||||
>
|
||||
<span className="text-lg">🛒</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-foreground/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-neon-green"></div>
|
||||
<span>In stock · Ships discreetly</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>🔐</span>
|
||||
<span>Encrypted</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
37
components/SecretLayer.tsx
Normal file
37
components/SecretLayer.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function SecretLayer() {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// In a real app, this would fetch from a database or listen to a WebSocket
|
||||
const savedMessage = localStorage.getItem("shadow_broadcast");
|
||||
if (savedMessage) {
|
||||
setMessage(savedMessage);
|
||||
}
|
||||
|
||||
// Listen for storage changes (if admin updates in another tab)
|
||||
const handleStorage = () => {
|
||||
setMessage(localStorage.getItem("shadow_broadcast"));
|
||||
};
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => window.removeEventListener("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 w-full bg-red-600 text-white py-2 px-4 text-center font-mono text-xs z-[9999] animate-pulse">
|
||||
<span className="font-bold uppercase mr-4">[ADMIN BROADCAST]:</span>
|
||||
{message}
|
||||
<button
|
||||
onClick={() => setMessage(null)}
|
||||
className="ml-4 underline opacity-50 hover:opacity-100"
|
||||
>
|
||||
DISMISS
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
components/WebringBadge.tsx
Normal file
31
components/WebringBadge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export function WebringBadge({ storeHref = "/" }: { storeHref?: string }) {
|
||||
return (
|
||||
<div className="mt-16 glass rounded-2xl border border-white/10 p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-orbitron text-lg font-bold text-neon-green">
|
||||
VERIFIED WORKING SITE
|
||||
</div>
|
||||
<div className="text-sm text-foreground/60">
|
||||
This microsite has been audited by the Department of Vibes and found to be
|
||||
suspiciously operational.
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={storeHref}
|
||||
className="inline-flex items-center justify-center rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-3 font-bold text-background"
|
||||
>
|
||||
Return to CyberLux →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-foreground/40">
|
||||
Disclaimer: parody universe. No real goods. No real money. Just immaculate confusion.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
94
components/barter/BarterPitChrome.tsx
Normal file
94
components/barter/BarterPitChrome.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
const LANES: { id: string; label: string }[] = [
|
||||
{ id: "goods", label: "Goods" },
|
||||
{ id: "services", label: "Labor" },
|
||||
{ id: "data", label: "Data" },
|
||||
{ id: "open", label: "Open" },
|
||||
];
|
||||
|
||||
function BarterPitChromeInner({ children }: { children: React.ReactNode }) {
|
||||
const sp = useSearchParams();
|
||||
const activeLane = sp.get("lane") ?? undefined;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#030806] text-[#b8f5c6]">
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(57,255,20,0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(57,255,20,0.05) 1px, transparent 1px)`,
|
||||
backgroundSize: "48px 48px",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none fixed inset-0 z-0 bg-[radial-gradient(ellipse_at_top,_rgba(0,80,40,0.35),_transparent_55%)]" />
|
||||
|
||||
<header className="relative z-20 border-b border-[#0d2818] bg-[#050a07]/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 px-4 py-5 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] uppercase tracking-[0.5em] text-[#39ff14]/55">unhosted barter mesh</p>
|
||||
<h1 className="mt-1 bg-gradient-to-r from-[#7cfc9aad] to-[#39ff14] bg-clip-text font-mono text-2xl font-black tracking-tight text-transparent md:text-3xl">
|
||||
ASH PIT
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl font-mono text-[11px] leading-relaxed text-[#6b9b78]/95">
|
||||
Trading floor for <span className="text-[#9af0a8]">swap terms</span> — no cart, no spot book. Post what you have,
|
||||
what you want, negotiate in-thread. Same session wallet as the hub when you need settlement.
|
||||
</p>
|
||||
</div>
|
||||
<nav className="flex flex-wrap gap-2 font-mono text-[10px] uppercase tracking-wider">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded border border-[#164024] bg-[#0a120d] px-3 py-2 text-[#8dcda0] hover:border-[#39ff14]/40"
|
||||
>
|
||||
Storefront
|
||||
</Link>
|
||||
<Link href="/exchange" className="rounded border border-[#164024] px-3 py-2 text-[#8dcda0] hover:border-[#39ff14]/40">
|
||||
Gazette
|
||||
</Link>
|
||||
<Link href="/forum" className="rounded border border-[#164024] px-3 py-2 text-[#8dcda0] hover:border-[#39ff14]/40">
|
||||
Forum
|
||||
</Link>
|
||||
<Link href="/checkout" className="rounded border border-[#39ff14]/35 bg-[#39ff14]/10 px-3 py-2 text-[#c8ffce] hover:bg-[#39ff14]/18">
|
||||
Settlement
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap gap-1 border-t border-[#0d2818] px-4 py-2">
|
||||
<Link
|
||||
href="/barter"
|
||||
className={`rounded px-2 py-1 font-mono text-[10px] uppercase ${
|
||||
!activeLane ? "bg-[#39ff14]/20 text-[#c8ffce]" : "text-[#5a7d62] hover:text-[#9af0a8]"
|
||||
}`}
|
||||
>
|
||||
All lanes
|
||||
</Link>
|
||||
{LANES.map((l) => (
|
||||
<Link
|
||||
key={l.id}
|
||||
href={`/barter?lane=${l.id}`}
|
||||
className={`rounded px-2 py-1 font-mono text-[10px] uppercase ${
|
||||
activeLane === l.id ? "bg-[#39ff14]/20 text-[#c8ffce]" : "text-[#5a7d62] hover:text-[#9af0a8]"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-4 py-10">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BarterPitChrome({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-[#030806] pt-24 text-center font-mono text-sm text-[#5a7d62]">Loading…</div>}>
|
||||
<BarterPitChromeInner>{children}</BarterPitChromeInner>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
124
components/exchange/ExchangeSiteChrome.tsx
Normal file
124
components/exchange/ExchangeSiteChrome.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import LexiconStrip from "@/components/site/LexiconStrip";
|
||||
import { EXCHANGE_ATMOSPHERE_TAGS } from "@/lib/cyberluxZoneLexicon";
|
||||
|
||||
const SECTIONS: { href: string; label: string; blurb: string }[] = [
|
||||
{ href: "/exchange", label: "The board", blurb: "WTS / WTB stream + submit" },
|
||||
{ href: "/exchange/guidelines", label: "Guidelines", blurb: "Posting law & risk rails" },
|
||||
{ href: "/exchange/ledger", label: "Ledger view", blurb: "Volume, mix, freshness" },
|
||||
];
|
||||
|
||||
export default function ExchangeSiteChrome({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname() ?? "";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0c0a08] text-[#d6cbb8] font-serif">
|
||||
<div className="border-b-4 border-double border-[#5c4d3d] bg-[#14100c]">
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<p className="text-center text-[10px] font-bold uppercase tracking-[0.5em] text-[#8a7b6b]">
|
||||
Weekly stranger gazette · multi-section
|
||||
</p>
|
||||
<Link href="/exchange" className="block">
|
||||
<h1 className="mt-2 text-center text-5xl font-black capitalize leading-none tracking-tight text-[#f0e6d8] md:text-6xl">
|
||||
The Exchange
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mt-3 text-center text-xs italic text-[#8a7b6b]">
|
||||
Classifieds spine — same wallet session as the hub. Board, policy, and ledger are first-class routes, not bolt-on tabs.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-4 border-y border-[#3d3228] py-3 text-xs font-bold uppercase tracking-wide text-[#a89888]">
|
||||
<Link href="/" className="text-[#d4a574] underline decoration-2 underline-offset-4 hover:text-[#e8c49a]">
|
||||
Hub
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/market" className="text-[#d4a574] underline decoration-2 underline-offset-4 hover:text-[#e8c49a]">
|
||||
Market
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/forum" className="text-[#d4a574] underline decoration-2 underline-offset-4 hover:text-[#e8c49a]">
|
||||
Forum
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/barter" className="text-[#7cfc9a] underline decoration-2 underline-offset-4 hover:text-[#b7ffca]">
|
||||
Barter
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/search" className="text-[#9ad7aa] underline decoration-2 underline-offset-4 hover:text-[#d5f5dc]">
|
||||
Crawler
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/hidden-wiki" className="text-[#94a8c4] underline decoration-2 underline-offset-4 hover:text-[#d8e4f2]">
|
||||
Wiki
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/darknet-atlas" className="text-[#9fc7ff] underline decoration-2 underline-offset-4 hover:text-[#dcebff]">
|
||||
Atlas
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/syndicate" className="text-fuchsia-200 underline decoration-2 underline-offset-4 hover:text-fuchsia-100">
|
||||
Syndicate
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/dashboard" className="text-[#d4a574] underline decoration-2 underline-offset-4 hover:text-[#e8c49a]">
|
||||
Dashboard
|
||||
</Link>
|
||||
<span className="text-[#4a3f36]">|</span>
|
||||
<Link href="/account/hidden-services" className="text-[#d4a574] underline decoration-2 underline-offset-4 hover:text-[#e8c49a]">
|
||||
Mirrors
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl px-4 pb-3 text-[#6b5c4d]">
|
||||
<LexiconStrip label="Gazette environment · trade lexicon" tags={EXCHANGE_ATMOSPHERE_TAGS} className="border-[#3d3228]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-[#3d3228] bg-[#100d0a] lg:hidden">
|
||||
<div className="mx-auto flex max-w-6xl gap-2 overflow-x-auto px-4 py-2">
|
||||
{SECTIONS.map((s) => (
|
||||
<Link
|
||||
key={s.href}
|
||||
href={s.href}
|
||||
className={`shrink-0 rounded border px-3 py-1.5 font-mono text-[10px] font-bold uppercase tracking-wide ${
|
||||
pathname === s.href
|
||||
? "border-[#c4a574] bg-[#c4a574]/15 text-[#f0e6d8]"
|
||||
: "border-[#4a3f36] text-[#8a7b6b] hover:border-[#8d6e4a]"
|
||||
}`}
|
||||
>
|
||||
{s.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-6xl gap-8 px-4 pb-24 pt-6">
|
||||
<aside className="hidden w-56 shrink-0 font-mono lg:block">
|
||||
<div className="sticky top-6 space-y-2 border-r border-[#3d3228] pr-4">
|
||||
<p className="text-[9px] font-bold uppercase tracking-[0.3em] text-[#6b5c4d]">Sections</p>
|
||||
{SECTIONS.map((s) => {
|
||||
const active = pathname === s.href;
|
||||
return (
|
||||
<Link
|
||||
key={s.href}
|
||||
href={s.href}
|
||||
className={`block rounded border px-3 py-2 text-left transition ${
|
||||
active ? "border-[#c4a574]/60 bg-[#1a1612]" : "border-transparent hover:border-[#4a3f36]"
|
||||
}`}
|
||||
>
|
||||
<span className={`text-[11px] font-bold uppercase ${active ? "text-[#e8c49a]" : "text-[#a89888]"}`}>
|
||||
{s.label}
|
||||
</span>
|
||||
<p className="mt-0.5 text-[9px] leading-snug text-[#6b5c4d]">{s.blurb}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
components/forum/ForumFeedToolbar.tsx
Normal file
101
components/forum/ForumFeedToolbar.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { FORUM_RINGS } from "@/lib/forumRings";
|
||||
import type { ForumSortMode } from "@/lib/forumSort";
|
||||
|
||||
type Props = {
|
||||
sort: ForumSortMode;
|
||||
onSortChange: (m: ForumSortMode) => void;
|
||||
search: string;
|
||||
onSearchChange: (q: string) => void;
|
||||
/** When set, show ring chips + `null` = all rings. Omit `onRingChange` to hide chips (ring sub-page). */
|
||||
ringFilter: string | null;
|
||||
onRingChange?: (slug: string | null) => void;
|
||||
extraRight?: ReactNode;
|
||||
threadCount?: number;
|
||||
hydrated?: boolean;
|
||||
};
|
||||
|
||||
export default function ForumFeedToolbar({
|
||||
sort,
|
||||
onSortChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
ringFilter,
|
||||
onRingChange,
|
||||
extraRight,
|
||||
threadCount,
|
||||
hydrated,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className="space-y-4 border-b border-[#3d3228] pb-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label className="min-w-0 flex-1 font-mono text-[10px] uppercase tracking-wider text-[#8a7d6b]">
|
||||
<span className="mb-1 block text-[#ff9a3c]/70">Search threads</span>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Title, body, author, ring…"
|
||||
className="w-full rounded border border-[#3d3228] bg-[#0d0a07] px-3 py-2 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c]/60 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{(["hot", "new", "top"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onSortChange(m)}
|
||||
className={`rounded px-3 py-1.5 font-mono text-[11px] font-bold uppercase tracking-wider ${
|
||||
sort === m ? "bg-[#c45c26]/40 text-[#ffcb8a]" : "text-[#8a7d6b] hover:text-[#ffcb8a]"
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onRingChange ? (
|
||||
<div className="flex flex-wrap items-center gap-2 font-mono text-[10px] uppercase tracking-wider">
|
||||
<span className="text-[#5c5044]">Ring</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRingChange(null)}
|
||||
className={`rounded px-2.5 py-1 font-bold ${
|
||||
ringFilter === null ? "bg-[#3d3228] text-[#ffcb8a]" : "text-[#8a7d6b] hover:text-[#ffcb8a]"
|
||||
}`}
|
||||
>
|
||||
all
|
||||
</button>
|
||||
{FORUM_RINGS.map((r) => (
|
||||
<button
|
||||
key={r.slug}
|
||||
type="button"
|
||||
onClick={() => onRingChange(r.slug)}
|
||||
className={`rounded px-2.5 py-1 font-bold ${
|
||||
ringFilter === r.slug ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "text-[#8a7d6b] hover:text-[#ffcb8a]"
|
||||
}`}
|
||||
>
|
||||
r/{r.shortName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 font-mono text-[10px] text-[#5c5044]">
|
||||
<span>{hydrated === false ? "…" : threadCount !== undefined ? `${threadCount} threads` : null}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{extraRight}
|
||||
<Link
|
||||
href="/forum/submit"
|
||||
className="rounded border border-[#ff9a3c]/60 px-3 py-1 font-bold uppercase text-[#ffcb8a] hover:bg-[#ff9a3c]/15"
|
||||
>
|
||||
+ New post
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
components/forum/ForumPostCard.tsx
Normal file
74
components/forum/ForumPostCard.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { formatRingLabel } from "@/lib/forumRings";
|
||||
import { displayScore, isPinnedThread, isReadOnlyThread, type ForumThread } from "@/lib/forumState";
|
||||
import { ThreadVoteColumn } from "@/components/forum/ThreadVoteColumn";
|
||||
|
||||
export function ForumPostCard({
|
||||
thread,
|
||||
onVoteChange,
|
||||
}: {
|
||||
thread: ForumThread;
|
||||
/** Fire when vote changes so list meta (score) re-renders from localStorage */
|
||||
onVoteChange?: () => void;
|
||||
}) {
|
||||
const pinned = isPinnedThread(thread);
|
||||
const locked = isReadOnlyThread(thread.id);
|
||||
const score = displayScore(thread);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-3 rounded-lg border bg-[#120e0a]/90 p-3 transition-colors ${
|
||||
pinned
|
||||
? "border-[#c45c26]/65 shadow-[0_0_0_1px_rgba(196,92,38,0.12)] hover:border-[#ff9a3c]/55"
|
||||
: "border-[#3d3228] hover:border-[#c45c26]/45"
|
||||
}`}
|
||||
>
|
||||
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
|
||||
<ThreadVoteColumn thread={thread} dense onVoteChange={onVoteChange} />
|
||||
</div>
|
||||
<Link href={`/forum/post/${thread.id}`} className="min-w-0 flex-1 outline-none">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{pinned ? (
|
||||
<span className="rounded bg-[#c45c26]/35 px-2 py-0.5 font-mono text-[9px] font-black uppercase tracking-widest text-[#ffcb8a]">
|
||||
Pinned
|
||||
</span>
|
||||
) : null}
|
||||
{locked ? (
|
||||
<span className="rounded border border-[#5c5044] px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[#8a7d6b]">
|
||||
Staff
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[10px] uppercase tracking-wider text-[#8a7d6b]">
|
||||
<span className="text-[#ff9a3c]/90">{formatRingLabel(thread.topicSlug)}</span>
|
||||
<span className="mx-2 text-[#3d3228]">·</span>
|
||||
<span>{thread.author || "Anonymous"}</span>
|
||||
<span className="mx-2 text-[#3d3228]">·</span>
|
||||
<span>{formatAgo(thread.ts)}</span>
|
||||
<span className="mx-2 text-[#3d3228]">·</span>
|
||||
<span className="tabular-nums text-[#ffcb8a]/80">{score} pts</span>
|
||||
</p>
|
||||
<h3 className="mt-1 font-mono text-base font-bold leading-snug text-[#ffcb8a] hover:underline md:text-lg">
|
||||
{thread.title}
|
||||
</h3>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-[#b8a995]">{thread.body}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3 font-mono text-[11px] text-[#8a7d6b]">
|
||||
<span className="rounded border border-[#3d3228] px-2 py-0.5">{thread.replies.length} comments</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAgo(ts: number): string {
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 48) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ago`;
|
||||
}
|
||||
167
components/forum/ForumSiteChrome.tsx
Normal file
167
components/forum/ForumSiteChrome.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import LexiconStrip from "@/components/site/LexiconStrip";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { FORUM_RINGS, getRing } from "@/lib/forumRings";
|
||||
import { FORUM_ATMOSPHERE_TAGS } from "@/lib/cyberluxZoneLexicon";
|
||||
|
||||
const SITE_NAME = "Void Aggregate";
|
||||
const SITE_TAGLINE = "distributed rumor ledger";
|
||||
|
||||
function Scanlines() {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 opacity-[0.1]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.5) 3px)",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForumSiteChrome({ children }: { children: React.ReactNode }) {
|
||||
const { user, hydrated } = useAccount();
|
||||
const pathname = usePathname();
|
||||
const ringMatch = pathname?.match(/^\/forum\/ring\/([^/]+)/);
|
||||
const activeSlug = ringMatch?.[1] ?? null;
|
||||
const activeRing = activeSlug ? getRing(activeSlug) : null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#070604] text-[#e7d9c8]">
|
||||
<Scanlines />
|
||||
<header className="sticky top-0 z-20 border-b border-[#c45c26]/30 bg-[#0d0a07]/95 backdrop-blur-md">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<Link href="/forum" className="group block">
|
||||
<p className="text-[9px] font-mono uppercase tracking-[0.45em] text-[#ff9a3c]/70">{SITE_TAGLINE}</p>
|
||||
<h1 className="font-mono text-xl font-black tracking-tight text-[#ffcb8a] md:text-2xl">
|
||||
{SITE_NAME}
|
||||
<span className="ml-2 text-[10px] font-normal opacity-50 group-hover:opacity-80">v3</span>
|
||||
</h1>
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-mono uppercase tracking-wider">
|
||||
<span className="hidden rounded border border-[#5c4030] bg-[#1a1510] px-3 py-1.5 text-[#b8a995] sm:inline">
|
||||
onion forum
|
||||
</span>
|
||||
<Link
|
||||
href="/forum/submit"
|
||||
className="rounded border border-[#ff9a3c] bg-[#ff9a3c]/15 px-3 py-1.5 font-bold text-[#ffcb8a] hover:bg-[#ff9a3c]/25"
|
||||
>
|
||||
+ Submit
|
||||
</Link>
|
||||
{hydrated && user ? (
|
||||
<Link href="/dashboard" className="rounded border border-[#2a1810] px-3 py-1.5 text-[#c4a574] hover:border-[#ff9a3c]/45">
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/sign-in" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="rounded border border-[#ff9a3c]/50 bg-[#ff9a3c]/10 px-3 py-1.5 text-[#ffcb8a] hover:bg-[#ff9a3c]/20"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
<Link
|
||||
href="/account/hidden-services"
|
||||
className="rounded border border-[#5c4030] px-3 py-1.5 text-[#9a8f7d] hover:border-[#ff9a3c]/50"
|
||||
>
|
||||
Mirrors
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<Link href="/" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
|
||||
Hub
|
||||
</Link>
|
||||
<Link href="/exchange" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
|
||||
Exchange
|
||||
</Link>
|
||||
<Link href="/barter" className="rounded border border-[#1a3d24] bg-[#0a120d] px-3 py-1.5 text-[#7cfc9a] hover:border-[#39ff14]/40">
|
||||
Barter
|
||||
</Link>
|
||||
<Link href="/hidden-wiki" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
|
||||
Wiki
|
||||
</Link>
|
||||
<Link
|
||||
href="/darknet-atlas"
|
||||
className="rounded border border-[#1e3a5f] bg-[#0a1520] px-3 py-1.5 text-[#7eb8ff] hover:border-[#38bdf8]/50"
|
||||
>
|
||||
Atlas
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className="rounded border border-[#0d2818] bg-[#050a07] px-3 py-1.5 text-[#6bdc8e] hover:border-[#00ff41]/35"
|
||||
>
|
||||
Crawler
|
||||
</Link>
|
||||
<Link href="/chatter" className="rounded border border-[#5c4030] px-3 py-1.5 text-[#c4b5a0] hover:border-[#ff9a3c]/50">
|
||||
Hub chatter
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-2 border-t border-[#2a2218] px-4 py-2 text-[10px] font-mono text-[#8a7d6b]">
|
||||
<span className="uppercase tracking-widest text-[#c45c26]/90">Depth</span>
|
||||
<Link
|
||||
href="/forum/handbook"
|
||||
className={`rounded px-2 py-0.5 ${pathname?.startsWith("/forum/handbook") ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
|
||||
>
|
||||
handbook
|
||||
</Link>
|
||||
<Link
|
||||
href="/forum/moderation"
|
||||
className={`rounded px-2 py-0.5 ${pathname?.startsWith("/forum/moderation") ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
|
||||
>
|
||||
moderation
|
||||
</Link>
|
||||
<Link
|
||||
href="/forum/reputation"
|
||||
className={`rounded px-2 py-0.5 ${pathname?.startsWith("/forum/reputation") ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
|
||||
>
|
||||
reputation
|
||||
</Link>
|
||||
<span className="mx-1 hidden text-[#3d3228] sm:inline">|</span>
|
||||
<span className="uppercase tracking-widest text-[#ff9a3c]/80">Rings</span>
|
||||
<Link
|
||||
href="/forum"
|
||||
className={`rounded px-2 py-0.5 ${!activeSlug && pathname === "/forum" ? "bg-[#ff9a3c]/20 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
|
||||
>
|
||||
front page
|
||||
</Link>
|
||||
{FORUM_RINGS.map((r) => (
|
||||
<Link
|
||||
key={r.slug}
|
||||
href={`/forum/ring/${r.slug}`}
|
||||
className={`rounded px-2 py-0.5 ${activeSlug === r.slug ? "bg-[#c45c26]/35 text-[#ffcb8a]" : "hover:text-[#ffcb8a]"}`}
|
||||
>
|
||||
r/{r.shortName}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl border-t border-[#2a2218] px-4 text-[#6b5c4d]">
|
||||
<LexiconStrip label="Board environment · signal lexicon" tags={FORUM_ATMOSPHERE_TAGS} className="border-[#3d3228]" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-4 py-8">
|
||||
{activeRing ? (
|
||||
<div className="mb-8 rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-5">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#ff9a3c]/70">
|
||||
r/{activeRing.shortName}
|
||||
</p>
|
||||
<h2 className="mt-1 font-mono text-lg font-bold text-[#ffcb8a]">{activeRing.title}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-[#b8a995]">{activeRing.about}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-4 font-mono text-[11px] text-[#8a7d6b]">
|
||||
<span>{activeRing.members.toLocaleString()} subscribers</span>
|
||||
<span>{activeRing.activeNow.toLocaleString()} reading now</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
components/forum/ThreadVoteColumn.tsx
Normal file
64
components/forum/ThreadVoteColumn.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ForumThread } from "@/lib/forumState";
|
||||
import {
|
||||
displayScore,
|
||||
getVoteForThread,
|
||||
setVoteForThread,
|
||||
type VoteChoice,
|
||||
} from "@/lib/forumState";
|
||||
|
||||
export function ThreadVoteColumn({
|
||||
thread,
|
||||
dense,
|
||||
onVoteChange,
|
||||
}: {
|
||||
thread: ForumThread;
|
||||
dense?: boolean;
|
||||
onVoteChange?: () => void;
|
||||
}) {
|
||||
const [tick, setTick] = useState(0);
|
||||
const choice = getVoteForThread(thread.id);
|
||||
const score = displayScore(thread);
|
||||
|
||||
const apply = useCallback(
|
||||
(dir: 1 | -1) => {
|
||||
const cur = getVoteForThread(thread.id);
|
||||
let next: VoteChoice = dir;
|
||||
if (cur === dir) next = 0;
|
||||
setVoteForThread(thread.id, next);
|
||||
setTick((t) => t + 1);
|
||||
onVoteChange?.();
|
||||
},
|
||||
[thread.id, onVoteChange],
|
||||
);
|
||||
|
||||
void tick;
|
||||
|
||||
const btn = dense ? "px-1 py-0.5 text-[10px]" : "px-1.5 py-1 text-xs";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col items-center rounded border border-[#3d3228] bg-[#0d0a07] font-mono ${dense ? "min-w-[44px] py-1" : "min-w-[52px] py-2"}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Upvote"
|
||||
onClick={() => apply(1)}
|
||||
className={`${btn} leading-none text-[#8a7d6b] hover:text-[#ff9a3c] ${choice === 1 ? "text-[#ff9a3c]" : ""}`}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<span className="text-[11px] font-bold tabular-nums text-[#ffcb8a]">{score}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Downvote"
|
||||
onClick={() => apply(-1)}
|
||||
className={`${btn} leading-none text-[#8a7d6b] hover:text-[#6b9fff] ${choice === -1 ? "text-[#6b9fff]" : ""}`}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
components/layouts/ThemedLayout.tsx
Normal file
112
components/layouts/ThemedLayout.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export type ThemeName =
|
||||
| "dark"
|
||||
| "retro"
|
||||
| "minimal"
|
||||
| "hacker"
|
||||
| "mystic"
|
||||
| "brutalist"
|
||||
| "terminal"
|
||||
| "zine"
|
||||
| "editorial"
|
||||
| "institutional"
|
||||
| "nature";
|
||||
|
||||
interface ThemedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
theme: ThemeName;
|
||||
/** Legacy prop; use siteTitle if you prefer */
|
||||
title?: string;
|
||||
siteTitle?: string;
|
||||
siteSubtitle?: string;
|
||||
}
|
||||
|
||||
export default function ThemedLayout({
|
||||
children,
|
||||
theme,
|
||||
title,
|
||||
siteTitle,
|
||||
siteSubtitle,
|
||||
}: ThemedLayoutProps) {
|
||||
const heading = siteTitle ?? title ?? "CyberLux";
|
||||
const [nodeTag, setNodeTag] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNodeTag(Math.floor(Math.random() * 99999));
|
||||
}, []);
|
||||
|
||||
const themes: Record<ThemeName, string> = {
|
||||
dark: "bg-black text-white font-sans",
|
||||
retro: "bg-[#222] text-[#0f0] font-mono border-4 border-double border-[#0f0]",
|
||||
minimal: "bg-[#0d0d0d] text-[#e6e6e6] font-serif border border-white/10",
|
||||
hacker: "bg-[#050505] text-[#00ff41] font-mono",
|
||||
mystic: "bg-[#1a0f1a] text-[#d4af37] font-serif italic",
|
||||
brutalist: "bg-[#111] text-[#f5f5f5] font-black uppercase border-[10px] border-[#f5f5f5]",
|
||||
terminal: "bg-[#000080] text-white font-mono",
|
||||
zine: "bg-[#1a1a1a] text-[#ff3366] font-sans border-y-8 border-[#ffff00]",
|
||||
editorial:
|
||||
"bg-[#121018] text-[#e8e4dc] font-serif border-x-[12px] border-double border-[#6b5c3d]/60",
|
||||
institutional:
|
||||
"bg-[#0c1e3d] text-[#e8eef7] font-sans border-t-4 border-[#c5a022]",
|
||||
nature: "bg-[#0d1f14] text-[#c8e6c9] font-serif border-b-8 border-[#2e7d32]",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen p-8 ${themes[theme]}`}>
|
||||
<nav className="mb-12 flex flex-col gap-1 border-b pb-4 opacity-50 transition-opacity hover:opacity-100 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-xl font-bold tracking-widest uppercase">{heading}</div>
|
||||
{siteSubtitle ? (
|
||||
<div className="mt-1 text-[10px] font-normal uppercase tracking-wider opacity-80">
|
||||
{siteSubtitle}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 text-xs uppercase md:gap-6">
|
||||
<Link href="/" className="hover:underline">
|
||||
Hub
|
||||
</Link>
|
||||
<Link href="/market" className="hover:underline">
|
||||
Market
|
||||
</Link>
|
||||
<Link href="/forum" className="hover:underline">
|
||||
Forum
|
||||
</Link>
|
||||
<Link href="/exchange" className="hover:underline">
|
||||
Exchange
|
||||
</Link>
|
||||
<Link href="/barter" className="hover:underline">
|
||||
Barter
|
||||
</Link>
|
||||
<Link href="/search" className="hover:underline">
|
||||
Crawler
|
||||
</Link>
|
||||
<Link href="/hidden-wiki" className="hover:underline">
|
||||
Wiki
|
||||
</Link>
|
||||
<Link href="/darknet-atlas" className="hover:underline">
|
||||
Atlas
|
||||
</Link>
|
||||
<Link href="/syndicate" className="hover:underline">
|
||||
Syndicate
|
||||
</Link>
|
||||
<Link href="/vault" className="hover:underline">
|
||||
Vault
|
||||
</Link>
|
||||
<Link href="/webring" className="hover:underline">
|
||||
Webring
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="mx-auto max-w-4xl">{children}</main>
|
||||
<footer className="mt-20 border-t border-current pt-8 text-center text-[10px] uppercase tracking-[0.3em] opacity-30">
|
||||
<div className="mb-4 font-bold text-red-500"></div>
|
||||
Node {nodeTag ?? "—"} // Shadow Network // {new Date().getFullYear()}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
190
components/market/MarketSiteChrome.tsx
Normal file
190
components/market/MarketSiteChrome.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import LexiconStrip from "@/components/site/LexiconStrip";
|
||||
import { MARKET_ATMOSPHERE_TAGS } from "@/lib/cyberluxZoneLexicon";
|
||||
import { SHOP_PRODUCT_COUNT } from "@/lib/shopCatalog";
|
||||
import { SHOP_SELLER_COUNT } from "@/lib/shopSellers";
|
||||
|
||||
const LAYERS: {
|
||||
title: string;
|
||||
items: { href: string; label: string; blurb: string }[];
|
||||
}[] = [
|
||||
{
|
||||
title: "Layer 1 · Catalog",
|
||||
items: [
|
||||
{ href: "/market", label: "Full floor", blurb: "Search, facets, every SKU" },
|
||||
{ href: "/vendors", label: "Vendor hall", blurb: "Stalls & bios" },
|
||||
{ href: "/vendor/apply", label: "Onboard", blurb: "Bond & review queue" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Layer 2 · Commerce spine",
|
||||
items: [
|
||||
{ href: "/checkout", label: "Checkout", blurb: "Cart → settle" },
|
||||
{ href: "/market/analytics", label: "Floor analytics", blurb: "Velocity, mix, heat" },
|
||||
{ href: "/market/operations", label: "Operations", blurb: "Pipeline & SLAs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Layer 3 · Trust & intel",
|
||||
items: [
|
||||
{ href: "/market/trust", label: "Escrow & keys", blurb: "Settlement tiers" },
|
||||
{ href: "/market/intel", label: "Buyer intel", blurb: "Vetting & OPSEC" },
|
||||
{ href: "/support", label: "Support ladder", blurb: "Tickets & mirrors" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function MarketSiteChrome({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const crumbs = [
|
||||
{ href: "/", label: "Hub" },
|
||||
{ href: "/market", label: "Market" },
|
||||
];
|
||||
const tail = pathname.replace(/^\/market\/?/, "");
|
||||
if (tail && tail !== "") {
|
||||
const seg = tail.split("/")[0];
|
||||
const label =
|
||||
seg === "analytics"
|
||||
? "Analytics"
|
||||
: seg === "operations"
|
||||
? "Operations"
|
||||
: seg === "trust"
|
||||
? "Trust"
|
||||
: seg === "intel"
|
||||
? "Intel"
|
||||
: seg;
|
||||
crumbs.push({ href: pathname, label });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050806] text-[#c8ffd8]">
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(-12deg, transparent, transparent 40px, rgba(0,255,65,0.04) 41px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
The Candy Shop
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mt-1 max-w-xl font-mono text-[11px] text-[#00ff41]/55">
|
||||
<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>
|
||||
<nav className="mt-3 flex flex-wrap items-center gap-1 font-mono text-[10px] uppercase tracking-wider text-[#5a8f6a]">
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={c.href} className="flex items-center gap-1">
|
||||
{i > 0 ? <span className="text-[#00ff41]/25">/</span> : null}
|
||||
<Link href={c.href} className="hover:text-[#7af598]">
|
||||
{c.label}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 font-mono text-[10px] uppercase">
|
||||
<Link href="/" className="border border-[#00ff41]/30 px-3 py-2 text-[#8fccb0] hover:border-[#00ff41]/60">
|
||||
Hub
|
||||
</Link>
|
||||
<Link href="/forum" className="border border-[#c45c26]/35 px-3 py-2 text-[#e8b896] hover:border-[#ff9a3c]/50">
|
||||
Forum
|
||||
</Link>
|
||||
<Link href="/exchange" className="border border-[#8d6e4a]/40 px-3 py-2 text-[#d4c4b0] hover:border-[#c4a574]/60">
|
||||
Exchange
|
||||
</Link>
|
||||
<Link href="/barter" className="border border-[#1a3d24] bg-[#0a120d] px-3 py-2 text-[#7cfc9a] hover:border-[#39ff14]/40">
|
||||
Barter
|
||||
</Link>
|
||||
<Link href="/hidden-wiki" className="border border-[#3d5a80]/30 px-3 py-2 text-[#9fb5d2] hover:border-[#7dd3fc]/50">
|
||||
Wiki
|
||||
</Link>
|
||||
<Link href="/darknet-atlas" className="border border-[#1e3a5f] px-3 py-2 text-[#7eb8ff] hover:border-[#38bdf8]/50">
|
||||
Atlas
|
||||
</Link>
|
||||
<Link href="/syndicate" className="border border-fuchsia-900/40 px-3 py-2 text-fuchsia-200 hover:border-fuchsia-500/50">
|
||||
Syndicate
|
||||
</Link>
|
||||
<Link href="/search" className="border border-[#00ff41]/25 px-3 py-2 text-[#6bdc8e] hover:border-[#00ff41]/45">
|
||||
Crawler
|
||||
</Link>
|
||||
<Link href="/account/hidden-services" className="border border-[#00ff41]/20 px-3 py-2 text-[#82a98c] hover:border-[#00ff41]/45">
|
||||
Mirrors
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#00ff41]/10 lg:hidden">
|
||||
<div className="mx-auto flex max-w-[1600px] gap-2 overflow-x-auto px-4 py-2 scrollbar-none">
|
||||
{LAYERS.flatMap((L) =>
|
||||
L.items.map((it) => (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={it.href}
|
||||
className={`shrink-0 rounded border px-3 py-1.5 font-mono text-[10px] uppercase ${
|
||||
pathname === it.href
|
||||
? "border-[#00ff41] bg-[#00ff41]/15 text-[#b4ffcc]"
|
||||
: "border-[#00ff41]/20 text-[#6a9b7a] hover:border-[#00ff41]/40"
|
||||
}`}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-[1600px] border-t border-[#00ff41]/10 px-4 text-[#5a8f6a]">
|
||||
<LexiconStrip label="Floor environment · keyword fog" tags={MARKET_ATMOSPHERE_TAGS} className="border-[#00ff41]/15" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative z-10 mx-auto flex max-w-[1600px] gap-8 px-4 pb-24 pt-6">
|
||||
<aside className="hidden w-72 shrink-0 lg:block">
|
||||
<div className="sticky top-28 space-y-8">
|
||||
{LAYERS.map((layer) => (
|
||||
<div key={layer.title}>
|
||||
<h2 className="border-b border-[#00ff41]/15 pb-2 font-mono text-[9px] font-bold uppercase tracking-[0.28em] text-[#00ff41]/50">
|
||||
{layer.title}
|
||||
</h2>
|
||||
<ul className="mt-3 space-y-1">
|
||||
{layer.items.map((it) => {
|
||||
const active = pathname === it.href;
|
||||
return (
|
||||
<li key={it.href}>
|
||||
<Link
|
||||
href={it.href}
|
||||
className={`block rounded border px-3 py-2 transition ${
|
||||
active
|
||||
? "border-[#00ff41]/55 bg-[#00ff41]/10"
|
||||
: "border-transparent hover:border-[#00ff41]/25 hover:bg-[#00ff41]/05"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs font-bold text-[#b4ffcc]">{it.label}</span>
|
||||
<p className="mt-0.5 font-mono text-[10px] leading-snug text-[#5a8f6a]">{it.blurb}</p>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded border border-dashed border-[#00ff41]/20 p-3 font-mono text-[9px] leading-relaxed text-[#4a6b55]">
|
||||
Tor entry onions map headers to the same app — your cart and session follow signed mirror bundles, not separate shops.
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
components/shop/ProductSocialBlock.tsx
Normal file
75
components/shop/ProductSocialBlock.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import type { ProductSocialMetrics } from "@/lib/shopProductSocial";
|
||||
|
||||
function Stars({ value, size = "sm" }: { value: number; size?: "sm" | "xs" }) {
|
||||
const rounded = Math.round(value * 2) / 2;
|
||||
const full = Math.floor(rounded);
|
||||
const half = rounded - full >= 0.5;
|
||||
const empty = 5 - full - (half ? 1 : 0);
|
||||
const c = size === "xs" ? "text-[10px]" : "text-xs";
|
||||
return (
|
||||
<span className={`${c} tracking-tight text-amber-400`} aria-hidden>
|
||||
{"★".repeat(full)}
|
||||
{half ? <span className="text-amber-400/60">★</span> : null}
|
||||
<span className="text-foreground/22">{"☆".repeat(Math.max(0, empty))}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductSocialBlock({
|
||||
metrics,
|
||||
compact = false,
|
||||
}: {
|
||||
metrics: ProductSocialMetrics;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { avgRating, reviewCount, purchaseCount, boughtLast24h, testimonials } = metrics;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||
<Stars value={avgRating} size="xs" />
|
||||
<span className="font-mono text-[11px] font-bold tabular-nums text-foreground/90">{avgRating.toFixed(2)}</span>
|
||||
<span className="font-mono text-[10px] text-foreground/45">({reviewCount.toLocaleString()} reviews)</span>
|
||||
</div>
|
||||
<p className="font-mono text-[10px] text-emerald-400/80">
|
||||
{purchaseCount.toLocaleString()} sold · <span className="text-emerald-300/90">{boughtLast24h}</span> in 24h
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Stars value={avgRating} />
|
||||
<span className="font-orbitron text-sm font-bold tabular-nums">{avgRating.toFixed(2)}</span>
|
||||
<span className="text-xs text-foreground/50">({reviewCount.toLocaleString()} reviews)</span>
|
||||
</div>
|
||||
<p className="font-mono text-[11px] text-emerald-400/85">
|
||||
<span className="text-foreground/55">Purchases:</span>{" "}
|
||||
<strong className="text-emerald-300">{purchaseCount.toLocaleString()}</strong> total ·{" "}
|
||||
<strong className="text-emerald-300">{boughtLast24h}</strong> bought in last 24h
|
||||
</p>
|
||||
<div className="space-y-2 border-t border-white/10 pt-3">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-foreground/40">Recent buyers</p>
|
||||
{testimonials.slice(0, 2).map((t, i) => (
|
||||
<blockquote
|
||||
key={`${t.handle}-${i}`}
|
||||
className="rounded-lg border border-white/5 bg-white/[0.03] px-3 py-2 text-xs leading-snug text-foreground/75"
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center justify-between gap-2 font-mono text-[10px] text-foreground/45">
|
||||
<span className="text-neon-cyan/80">@{t.handle}</span>
|
||||
<span>
|
||||
<span className="text-amber-400">★{t.rating}</span> · {t.daysAgo}d ago
|
||||
</span>
|
||||
</div>
|
||||
“{t.text}”
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
components/shop/VendorShelfRow.tsx
Normal file
69
components/shop/VendorShelfRow.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
|
||||
import { resolveProductImage, type ShopCurrency, type ShopProduct } from "@/lib/shopCatalog";
|
||||
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
|
||||
|
||||
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 function VendorShelfRow({ product }: { product: ShopProduct }) {
|
||||
const { addToCart, hydrated } = useCart();
|
||||
const [flash, setFlash] = useState(false);
|
||||
const src = resolveProductImage(product);
|
||||
const metrics = getProductSocialMetrics(product.id);
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-4 rounded-lg border border-orange-950/70 bg-[#080605] p-4 sm:flex-row">
|
||||
<div className="relative h-28 w-full shrink-0 overflow-hidden rounded border border-orange-900/30 bg-black sm:h-28 sm:w-28">
|
||||
<Image src={src} alt="" fill className="object-cover" sizes="112px" unoptimized={src.startsWith("/")} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-mono text-[10px] uppercase tracking-wider text-orange-500/60">{product.category}</p>
|
||||
<p className="font-semibold text-orange-50">{product.name}</p>
|
||||
<div className="mt-2 text-[#c4b5a0]">
|
||||
<ProductSocialBlock metrics={metrics} compact />
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-xs text-orange-200/65">{product.description}</p>
|
||||
<p className="mt-2 font-mono text-sm text-orange-300">
|
||||
{SYM[product.currency]}
|
||||
{formatPrice(product.price, product.currency)} {product.currency}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2 border-t border-orange-950/50 pt-3">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-orange-500/50">Verified note</p>
|
||||
<p className="text-xs italic text-orange-200/60">“{metrics.testimonials[0]?.text}”</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hydrated}
|
||||
onClick={() => {
|
||||
addToCart(product, 1);
|
||||
setFlash(true);
|
||||
window.setTimeout(() => setFlash(false), 1500);
|
||||
}}
|
||||
className="rounded border border-orange-500 bg-orange-500/90 px-4 py-2 font-mono text-xs font-bold uppercase text-black hover:bg-orange-400 disabled:opacity-50"
|
||||
>
|
||||
{flash ? "Added ✓" : "Add to cart"}
|
||||
</button>
|
||||
<Link
|
||||
href="/checkout"
|
||||
className="rounded border border-orange-800 px-4 py-2 font-mono text-xs text-orange-200 hover:border-orange-600"
|
||||
>
|
||||
Cart
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
26
components/site/LexiconStrip.tsx
Normal file
26
components/site/LexiconStrip.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Diegetic keyword fog for deep vertical chrome — improves atmosphere and internal crawler token density. */
|
||||
export default function LexiconStrip({
|
||||
label,
|
||||
tags,
|
||||
className = "",
|
||||
}: {
|
||||
label: string;
|
||||
tags: readonly string[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={`border-t border-white/10 px-1 py-2 ${className}`}>
|
||||
<p className="mb-1.5 font-mono text-[8px] uppercase tracking-[0.35em] opacity-50">{label}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded border border-white/10 px-1.5 py-0.5 font-mono text-[8px] leading-none opacity-60"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
components/syndicate/HubStrip.tsx
Normal file
21
components/syndicate/HubStrip.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Link from "next/link";
|
||||
|
||||
/** Call-to-action back to the main storefront (same deployment). */
|
||||
export function HubStrip({ slug }: { slug: string }) {
|
||||
return (
|
||||
<div className="mb-8 border-2 border-current bg-black/30 px-4 py-3 text-xs md:text-sm">
|
||||
<span className="opacity-80">RELAY /{slug}</span>
|
||||
<span className="mx-2 opacity-40">|</span>
|
||||
<Link href="/" className="font-bold underline decoration-2 underline-offset-4 hover:opacity-80">
|
||||
Primary CyberLux storefront
|
||||
</Link>
|
||||
<span className="mx-2 opacity-40">·</span>
|
||||
<Link href="/checkout" className="underline opacity-90 hover:opacity-100">
|
||||
Checkout
|
||||
</Link>
|
||||
<p className="mt-2 text-[10px] opacity-60">
|
||||
Verify this .onion against the signed mirror list on the hub before signing anything. Unsigned relay = untrusted relay.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
components/syndicate/SyndicateSiteChrome.tsx
Normal file
62
components/syndicate/SyndicateSiteChrome.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import LexiconStrip from "@/components/site/LexiconStrip";
|
||||
import { SYNDICATE_ATMOSPHERE_TAGS } from "@/lib/cyberluxZoneLexicon";
|
||||
|
||||
const NAV: { href: string; label: string }[] = [
|
||||
{ href: "/syndicate", label: "Node index" },
|
||||
{ href: "/syndicate/topology", label: "Topology" },
|
||||
{ href: "/syndicate/routing", label: "Routing lore" },
|
||||
];
|
||||
|
||||
export default function SyndicateSiteChrome({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname() ?? "";
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-200">
|
||||
<header className="border-b border-zinc-800 px-4 py-8">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-fuchsia-400/80">syndicate · multi-layer</p>
|
||||
<Link href="/syndicate">
|
||||
<h1 className="mt-2 text-3xl font-bold tracking-tight text-white md:text-4xl">Syndicate shells</h1>
|
||||
</Link>
|
||||
<p className="mt-3 max-w-2xl text-sm text-zinc-400">
|
||||
Themed <code className="text-fuchsia-300/90">/w/*</code> relays on the same Next deployment — index, topology, and routing docs are separate pages so dedicated onions can deep-link without a single flat list.
|
||||
</p>
|
||||
<nav className="mt-6 flex flex-wrap gap-2 text-xs font-medium">
|
||||
{NAV.map((n) => {
|
||||
const active = n.href === "/syndicate" ? pathname === "/syndicate" : pathname.startsWith(n.href);
|
||||
return (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className={`rounded border px-3 py-2 ${
|
||||
active
|
||||
? "border-fuchsia-500 bg-fuchsia-500/15 text-fuchsia-100"
|
||||
: "border-zinc-700 text-zinc-300 hover:border-fuchsia-500/40"
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<Link href="/" className="rounded border border-zinc-700 px-3 py-2 text-zinc-400 hover:border-zinc-500">
|
||||
Hub
|
||||
</Link>
|
||||
<Link href="/hidden-wiki" className="rounded border border-zinc-700 px-3 py-2 text-zinc-400 hover:border-zinc-500">
|
||||
Wiki
|
||||
</Link>
|
||||
<Link href="/search" className="rounded border border-zinc-700 px-3 py-2 text-zinc-400 hover:border-zinc-500">
|
||||
Crawler
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="mt-6 text-zinc-500">
|
||||
<LexiconStrip label="Relay environment · operator lexicon" tags={SYNDICATE_ATMOSPHERE_TAGS} className="border-zinc-800" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="mx-auto max-w-4xl px-4 py-10">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user