Compare commits
29 Commits
52432dccfa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df075a308 | |||
| 8383912d62 | |||
| 740be59965 | |||
| 43e8196f74 | |||
| 81be87b75e | |||
| 81a73e9dc2 | |||
| 5a016091df | |||
| 5576375bac | |||
| 5d1a74597c | |||
| 1d541e6128 | |||
| 08a5ee85b1 | |||
| ed7aa92ad0 | |||
| 81cc2cfc4b | |||
| d3203a0f32 | |||
| f5dab4231b | |||
| e9c2d8fb04 | |||
| 7b61e274ad | |||
| f60939b863 | |||
| 7c4d983e6f | |||
|
|
e7c9da944e | ||
|
|
04d64fb993 | ||
|
|
cb072437d0 | ||
|
|
4354416f1c | ||
|
|
348e55b63e | ||
|
|
2f928fbdc4 | ||
|
|
9da373a190 | ||
|
|
e0a4a20862 | ||
|
|
1bbc761f58 | ||
|
|
78a071ba02 |
17
.editorconfig
Normal file
17
.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
||||
# EditorConfig is awesome: https://editorconfig.org
|
||||
<!-- stewardship-standard: editorconfig-v1 -->
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{md,markdown}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
27
.gitattributes
vendored
Normal file
27
.gitattributes
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# stewardship-standard: gitattributes-v1
|
||||
* text=auto eol=lf
|
||||
|
||||
*.md text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.py text eol=lf
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.c text eol=lf
|
||||
*.cpp text eol=lf
|
||||
*.h text eol=lf
|
||||
*.hpp text eol=lf
|
||||
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.webp binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
*.bin binary
|
||||
*.elf binary
|
||||
*.uf2 binary
|
||||
21
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
21
.gitea/ISSUE_TEMPLATE/bug_report.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Bug Report
|
||||
|
||||
## Summary
|
||||
|
||||
Describe the problem and expected behavior.
|
||||
|
||||
## Environment
|
||||
|
||||
- Repo version/commit:
|
||||
- OS/toolchain/board/service:
|
||||
- Relevant configuration with secrets removed:
|
||||
|
||||
## Reproduction
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Logs
|
||||
|
||||
Paste only sanitized logs. Remove credentials, tokens, personal data, captures, dumps, and target identifiers.
|
||||
13
.gitea/ISSUE_TEMPLATE/docs_task.md
Normal file
13
.gitea/ISSUE_TEMPLATE/docs_task.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Documentation Task
|
||||
|
||||
## Page Or Section
|
||||
|
||||
Name the README/wiki section that needs work.
|
||||
|
||||
## Change Needed
|
||||
|
||||
Describe what should be clearer, corrected, or added.
|
||||
|
||||
## Source Of Truth
|
||||
|
||||
Link to code, hardware notes, upstream docs, release notes, or maintainer decision.
|
||||
14
.gitea/ISSUE_TEMPLATE/release_checklist.md
Normal file
14
.gitea/ISSUE_TEMPLATE/release_checklist.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Release Checklist
|
||||
|
||||
## Scope
|
||||
|
||||
Describe what is being released and why.
|
||||
|
||||
## Checks
|
||||
|
||||
- [ ] README and wiki are current.
|
||||
- [ ] Changelog entry exists.
|
||||
- [ ] License/provenance is clear.
|
||||
- [ ] No secrets or private data are included.
|
||||
- [ ] Firmware/binary artifacts include SHA256 hashes and target details.
|
||||
- [ ] Build or smoke-check result is recorded.
|
||||
7
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
7
.gitea/PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Pull Request Checklist
|
||||
|
||||
- [ ] Scope is clear and limited.
|
||||
- [ ] README/wiki updates are included when behavior, setup, hardware, or release process changes.
|
||||
- [ ] No secrets, tokens, private data, dumps, captures, or generated dependency folders are committed.
|
||||
- [ ] Build/test/smoke-check result is documented.
|
||||
- [ ] License or upstream provenance is preserved.
|
||||
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# runtime-generated onion URL file (populated by scripts/export-onion-urls.sh)
|
||||
onion-urls.txt
|
||||
|
||||
# runtime data
|
||||
.messages.json
|
||||
/logs/
|
||||
|
||||
# vanity onion mining (mkp224o output + upstream clone)
|
||||
/vendor/mkp224o/
|
||||
/var/vanity-mine/
|
||||
|
||||
# server-side credit ledger (runtime state — do not commit)
|
||||
/var/server-ledger.json
|
||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
All meaningful changes to this repository should be recorded here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Add future changes here before tagging or publishing release artifacts.
|
||||
|
||||
## 2026-05-20 - Gitea Stewardship Import
|
||||
|
||||
- Verified README and wiki coverage.
|
||||
- Added standard stewardship documentation where missing.
|
||||
- Established security, contribution, release, and provenance expectations.
|
||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@@ -0,0 +1 @@
|
||||
* @drjones
|
||||
20
CONTRIBUTING.md
Normal file
20
CONTRIBUTING.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Contributing
|
||||
|
||||
## Maintainer Expectations
|
||||
|
||||
Keep changes small, reviewable, and tied to a clear project purpose. Do not mix source changes with generated build output or dependency caches.
|
||||
|
||||
## Before Committing
|
||||
|
||||
- Run the relevant build, lint, or smoke test when the project provides one.
|
||||
- Check that no credentials, `.env` files, tokens, private keys, captures, dumps, or personal data are staged.
|
||||
- Keep firmware binaries, large archives, and generated artifacts out of Git unless the repo explicitly documents otherwise.
|
||||
- Preserve upstream licenses and attribution for third-party code.
|
||||
|
||||
## Documentation
|
||||
|
||||
Update README and wiki pages when setup, hardware, architecture, environment variables, or release behavior changes.
|
||||
|
||||
## Safety
|
||||
|
||||
Only submit work intended for authorized environments. Project documentation should make scope and safe operation clearer, never weaker.
|
||||
129
DEPLOY.md
Normal file
129
DEPLOY.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# CyberLux — production on Tor (.onion)
|
||||
|
||||
This app is designed to run **behind nginx on loopback**, with **one Tor v3 hidden service for the whole site**. Nginx listens on `127.0.0.1:8080` (see `scripts/onion-nodes.json`); Tor forwards port 80 on the onion to that loopback port. **Next.js binds only `127.0.0.1:3000`** — never expose 3000 or the nginx loopback port to the public internet.
|
||||
|
||||
Onion hostnames are **created locally** when Tor first starts; there are no fixed `.onion` URLs in the repo. Back up `/var/lib/tor/cyberlux/hs_ed25519_secret_key` (the install flow uses `scripts/backup-onion-keys.sh`). A vanity prefix such as `cyberlux...onion` requires generating or importing a matching Tor v3 hidden-service key.
|
||||
|
||||
## One-time server setup (Debian/Ubuntu-style)
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y tor nginx curl nodejs npm build-essential
|
||||
# Or install Node.js LTS from NodeSource / nvm — `node` and `npm` must be on PATH.
|
||||
```
|
||||
|
||||
## Deploy the app
|
||||
|
||||
From the repo root (as the user that will own the process):
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
```
|
||||
|
||||
This will: regenerate Tor/nginx maps from `scripts/onion-nodes.json`, `npm install`, `npm run build`, install Tor+nginx configs (`sudo`), wait for the hostname file, print the `.onion` URL, then **foreground** `next start` on `127.0.0.1:3000`.
|
||||
|
||||
For a **one-shot prepare** (build + Tor/nginx, no Next.js — for systemd):
|
||||
|
||||
```bash
|
||||
CYBERLUX_PREPARE_ONLY=1 ./start.sh
|
||||
```
|
||||
|
||||
## systemd — start on boot
|
||||
|
||||
1. Ensure `./start.sh` or `CYBERLUX_PREPARE_ONLY=1 ./start.sh` has been run at least once so `.next` exists and Tor directories are populated.
|
||||
2. Install the unit (run as root; set user to the account that owns the repo):
|
||||
|
||||
```bash
|
||||
sudo CYBERLUX_USER=youruser bash scripts/install-systemd.sh
|
||||
# optional: sudo CYBERLUX_CHOWN_REPO=1 CYBERLUX_USER=youruser bash scripts/install-systemd.sh
|
||||
```
|
||||
|
||||
3. Enable **Tor**, **nginx**, and **CyberLux** at boot:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable tor.service nginx.service cyberlux.service
|
||||
# if your distro uses tor@default instead of tor:
|
||||
# sudo systemctl enable tor@default.service nginx.service cyberlux.service
|
||||
sudo systemctl start tor.service nginx.service cyberlux.service
|
||||
```
|
||||
|
||||
4. Check logs:
|
||||
|
||||
```bash
|
||||
journalctl -u cyberlux.service -f
|
||||
```
|
||||
|
||||
5. Health check (local):
|
||||
|
||||
```bash
|
||||
npm run health:stack
|
||||
```
|
||||
|
||||
6. **List the `.onion` URL** and **check that the nginx loopback vhost answers** (needs Tor running; use `sudo` if hostname files are root-only):
|
||||
|
||||
```bash
|
||||
npm run onions:status
|
||||
# or:
|
||||
sudo node scripts/onion-status.cjs
|
||||
```
|
||||
|
||||
## Changing the onion map
|
||||
|
||||
1. Edit `scripts/onion-nodes.json`.
|
||||
2. Run `node scripts/generate-onion-config.cjs` (or `npm run build`, which runs it in `prebuild`).
|
||||
3. `sudo bash scripts/install-tor-onion.sh`
|
||||
4. Rebuild/restart the app: `npm run build` and `sudo systemctl restart cyberlux.service`
|
||||
|
||||
## 502 Bad Gateway on `.onion` sites
|
||||
|
||||
Tor and nginx are working, but **nginx proxies to Next.js on `127.0.0.1:3000`**. A **502** means **nothing is listening there** (Next is stopped, crashed, or never started after reboot).
|
||||
|
||||
1. **Confirm** (from the repo):
|
||||
|
||||
```bash
|
||||
curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/
|
||||
```
|
||||
|
||||
`000` = connection refused → Next is down.
|
||||
|
||||
2. **Start Next** (pick one):
|
||||
|
||||
- **Foreground (dev / quick test):** `cd /path/to/cyberlux && npm run start:onion` — leave the terminal open.
|
||||
- **systemd (production):** `sudo systemctl start cyberlux.service` — ensure the unit is installed (`scripts/install-systemd.sh`) and enabled.
|
||||
- **Full stack script:** `./start.sh` (builds, configures Tor/nginx if needed, then starts Next).
|
||||
|
||||
3. **Verify again:**
|
||||
|
||||
```bash
|
||||
npm run health:stack
|
||||
```
|
||||
|
||||
You want `Next.js: OK` and `hub vhost: OK` (HTTP 200/301/302/304).
|
||||
|
||||
4. **If it still fails:** `journalctl -u cyberlux.service -n 80 --no-pager` — look for crash loops, missing `.next` (run `npm run build`), or wrong `WorkingDirectory` in the unit.
|
||||
|
||||
## Custom .onion prefix (branded / vanity name)
|
||||
|
||||
v3 hostnames are random unless you **mine** a key with [mkp224o](https://github.com/cathugger/mkp224o) and install it. That is a **separate, long-running CPU** step — not part of a normal deploy.
|
||||
|
||||
1. `bash scripts/mkp224o-build.sh` (or Docker; see `ONION-URLS.md`)
|
||||
2. `bash scripts/vanity-onion-mine.sh <base32_prefix>`
|
||||
3. `sudo bash scripts/install-vanity-onion-key.sh var/vanity-mine`
|
||||
|
||||
Full details: [`ONION-URLS.md`](ONION-URLS.md#vanity-onion-custom-prefix-eg-site-name-at-the-start).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
## Security & compliance
|
||||
|
||||
- You are responsible for **local law**, hosting terms, and **Tor / relay policies**. This repo is a **parody web app**; treat operational security seriously if you run it on a real server.
|
||||
|
||||
## Security notes
|
||||
|
||||
- Only **Tor** should be reachable from outside; bind **nothing** to `0.0.0.0` for this stack.
|
||||
- Prefer firewall defaults that deny incoming except what you need for SSH.
|
||||
- Optional: `sudo bash scripts/classroom-ufw.sh` (if present) for a restrictive UFW profile.
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 sudo-jones-cmd
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
14
LICENSE_STATUS.md
Normal file
14
LICENSE_STATUS.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# License Status
|
||||
|
||||
This repository has not been assigned a blanket license by the stewardship pass.
|
||||
|
||||
## Current Rule
|
||||
|
||||
- Existing upstream licenses must be preserved.
|
||||
- Third-party code must retain attribution and license files.
|
||||
- Original private work remains all rights reserved until an explicit license is selected.
|
||||
- Do not assume MIT, Apache, GPL, or public-domain status unless a license file in this repository says so.
|
||||
|
||||
## Next Step
|
||||
|
||||
Classify ownership and dependencies before publishing releases or accepting external contributions.
|
||||
135
ONION-URLS.md
Normal file
135
ONION-URLS.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# CyberLux — Onion URL Reference
|
||||
|
||||
CyberLux now uses one Tor v3 hidden service for the whole site. The live `.onion` address is generated at runtime by Tor and stored in `/var/lib/tor/cyberlux/hostname`. This file is a **static registry**; use the commands below to resolve the live address.
|
||||
|
||||
---
|
||||
|
||||
## Get Live URLs
|
||||
|
||||
```bash
|
||||
# Print the live .onion URL (reads /var/lib/tor/cyberlux/hostname)
|
||||
bash scripts/list-onion-urls.sh
|
||||
# or with sudo if not readable as your user:
|
||||
sudo bash scripts/list-onion-urls.sh
|
||||
|
||||
# Save live URLs to onion-urls.txt in the repo root
|
||||
sudo bash scripts/export-onion-urls.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Registry
|
||||
|
||||
| # | `torDir` | Port | Role | App Path |
|
||||
|---|----------|------|------|----------|
|
||||
| 1 | `cyberlux` | 8080 | Everything site — hub, market, forum, directory, wiki, account, and all app paths | `/directory` |
|
||||
|
||||
---
|
||||
|
||||
## Live Address File
|
||||
|
||||
After Tor is running, save all live addresses to a plain-text file:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/export-onion-urls.sh
|
||||
# writes: onion-urls.txt
|
||||
```
|
||||
|
||||
`onion-urls.txt` (if present in this directory) contains the resolved `http://…56chars….onion` address and is gitignored — it reflects runtime state, not source.
|
||||
|
||||
---
|
||||
|
||||
## How Addresses Stay Stable
|
||||
|
||||
The service maps to a fixed `HiddenServiceDir` in `/var/lib/tor/`. As long as:
|
||||
|
||||
- the `torDir` name in `scripts/onion-nodes.json` stays `cyberlux`, and
|
||||
- `/var/lib/tor/cyberlux/` is preserved (or restored from backup),
|
||||
|
||||
the `.onion` address **never rotates**, surviving reboots, rebuilds, and Tor restarts.
|
||||
|
||||
Backup/restore:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/backup-onion-keys.sh # saves to /var/backups/cyberlux-onion-keys/current
|
||||
sudo bash scripts/restore-onion-keys.sh # restores missing dirs from backup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vanity .onion (custom prefix, e.g. site name at the start)
|
||||
|
||||
v3 onion names are random unless you **generate** a key pair whose address matches a **prefix** (brute force). This repo ships scripts around **[mkp224o](https://github.com/cathugger/mkp224o)** (same approach as the [Tor community vanity guide](https://community.torproject.org/onion-services/advanced/vanity-addresses/)).
|
||||
|
||||
**Charset:** Onion v3 hostnames are base32: **a–z** and **2–7** only (no `0`, `1`, `8`, `9`). Any other character in the filter is invalid.
|
||||
|
||||
**Time:** Shorter prefixes finish sooner; each extra character is much harder. Treat long targets (e.g. full word) as a **long** CPU run or use many cores / a batch-capable build per mkp224o’s `OPTIMISATION.txt`.
|
||||
|
||||
### 1) Build the miner (once)
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu deps (as root)
|
||||
sudo apt install -y git gcc make autoconf pkg-config libc6-dev libsodium-dev
|
||||
|
||||
cd /path/to/cyberlux
|
||||
bash scripts/mkp224o-build.sh
|
||||
# produces: vendor/mkp224o/mkp224o
|
||||
```
|
||||
|
||||
`vendor/mkp224o` is gitignored; nothing is committed except these scripts.
|
||||
|
||||
**Docker alternative (no local compile):**
|
||||
|
||||
```bash
|
||||
export CYBERLUX_MKP224O_DOCKER=1
|
||||
export CYBERLUX_VANITY_OUT="/path/to/cyberlux/var/vanity-mine"
|
||||
mkdir -p "$CYBERLUX_VANITY_OUT"
|
||||
bash scripts/vanity-onion-mine.sh yourprefix
|
||||
```
|
||||
|
||||
(Requires Docker; uses `ghcr.io/cathugger/mkp224o:master`.)
|
||||
|
||||
### 2) Mine a prefix (runs until a match appears)
|
||||
|
||||
```bash
|
||||
bash scripts/vanity-onion-mine.sh cyberl
|
||||
# Output directory: var/vanity-mine/ (default) — gitignored
|
||||
```
|
||||
|
||||
You get a new subdirectory named like `cyberlXXXXXXXX.onion` containing `hs_ed25519_secret_key` and `hostname`. **Stop the miner** when you have a directory you are happy with if it created more than one.
|
||||
|
||||
### 3) Install the key for CyberLux and restart Tor
|
||||
|
||||
`HiddenServiceDir` in this project is `/var/lib/tor/cyberlux` (from `onion-nodes.json`). The install script copies the **files** from the mkp224o folder into that directory, fixes ownership, and restarts Tor.
|
||||
|
||||
```bash
|
||||
# If var/vanity-mine has several *.onion folders, the script picks one for you
|
||||
# (default: first in A–Z order). Newest mtime: CYBERLUX_VANITY_PICK=latest
|
||||
sudo bash scripts/install-vanity-onion-key.sh var/vanity-mine
|
||||
sudo env CYBERLUX_VANITY_PICK=latest bash scripts/install-vanity-onion-key.sh var/vanity-mine
|
||||
|
||||
# Or point at one directory explicitly:
|
||||
sudo bash scripts/install-vanity-onion-key.sh var/vanity-mine/cyberlxxxxx....onion
|
||||
```
|
||||
|
||||
The previous service directory is moved to `/var/lib/tor/cyberlux.stale.<timestamp>`. A backup is triggered when `backup-onion-keys.sh` is present.
|
||||
|
||||
### 4) Confirm the new URL
|
||||
|
||||
```bash
|
||||
bash scripts/list-onion-urls.sh
|
||||
# or
|
||||
sudo cat /var/lib/tor/cyberlux/hostname
|
||||
```
|
||||
|
||||
Open `http://<address>.onion` in **Tor Browser**.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The service runs from **one** Next.js instance at `127.0.0.1:3000`.
|
||||
- nginx on `127.0.0.1:8080` proxies every path to Next.
|
||||
- Open addresses in **Tor Browser** or **Onion Browser** (iOS). Standard browsers cannot resolve `.onion`.
|
||||
- Use `http://` — TLS inside the onion circuit is handled end-to-end by Tor.
|
||||
- A vanity prefix is **not** “configured” in `torrc`; only the private key in `HiddenServiceDir` determines the hostname.
|
||||
244
README.md
244
README.md
@@ -1,67 +1,235 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# CYBERLUX
|
||||
|
||||
## Getting Started
|
||||
> **The clearnet is a showroom. The onion is the door. One engine behind every door.**
|
||||
|
||||
First, run the development server:
|
||||
One Next.js stack projected through a single Tor v3 hidden service. Nginx sits in the middle; Tor does the publishing. Same codebase, same deploy — **one `.onion` front door** with every section available as a normal app path.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Tor Browser
|
||||
│ http://<56-char>.onion
|
||||
▼
|
||||
[ Tor Network ]
|
||||
│
|
||||
▼
|
||||
nginx 127.0.0.1:8080 (single onion vhost)
|
||||
│
|
||||
▼
|
||||
Next.js 127.0.0.1:3000 (single instance, proxy.ts routes by header)
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
**One onion service → one app → zero public ports.**
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
---
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## CyberLux on Tor (classroom / demo)
|
||||
|
||||
The UI uses fictional `.onion` names in places; **live addresses come from your Tor daemon** after you install the hidden services.
|
||||
|
||||
**One-command launch:** install `tor` and `nginx` (`apt install tor nginx` on Debian/Ubuntu), then:
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
chmod +x start.sh # once
|
||||
git pull && ./start.sh
|
||||
sudo apt install tor nginx
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
The first step regenerates configs from `scripts/onion-nodes.json` (43 Tor v3 services → loopback `8080–8122`, one `.onion` per top-level area). Then it runs `git pull` if `.git` exists (failure is non-fatal), `npm install` / `npm run build`. If `.next/` is **not writable** (common after a `sudo npm run build`), the script runs **`sudo chown`** on `.next` for you. It then installs Tor + nginx via `sudo` (skip with `CYBERLUX_SKIP_TOR=1` for local-only), prints **every** `.onion` URL, and starts Next on `127.0.0.1:3000`. Use **Tor Browser** with `http://YOUR56CHAR.onion`.
|
||||
Open the hub in **Tor Browser** (addresses print at the end of start.sh).
|
||||
Local-only (skip Tor/nginx): `CYBERLUX_SKIP_TOR=1 ./start.sh`
|
||||
|
||||
To add or reorder onions, edit `scripts/onion-nodes.json` and run `node scripts/generate-onion-config.cjs` (also runs automatically on `npm run build` and via `./start.sh`).
|
||||
---
|
||||
|
||||
Manual steps (equivalent): `sudo bash scripts/install-tor-onion.sh`, then `npm run build && npm run start:onion`.
|
||||
## Operator Commands
|
||||
|
||||
**Start at boot (systemd):** copy `scripts/cyberlux.service`, set `User`, `Group`, and `WorkingDirectory` to your clone, `sudo install -m 0644 scripts/cyberlux.service /etc/systemd/system/`, then `sudo systemctl daemon-reload && sudo systemctl enable --now cyberlux.service`. Tor and nginx should already be enabled; this only keeps Next running after reboot.
|
||||
| Command | What it does |
|
||||
|---------|-------------|
|
||||
| `./start.sh` | Full pipeline: generate → build → Tor/nginx → print URLs → start Next |
|
||||
| `npm run onions:list` | Print the live `.onion` URL from `/var/lib/tor/cyberlux/hostname` |
|
||||
| `sudo npm run onions:export` | Write the live URL to `onion-urls.txt` in repo root |
|
||||
| `npm run onions:status` | URL + HTTP probe for the nginx vhost |
|
||||
| `npm run health:stack` | Curl Tor/nginx/Next loopbacks and report status |
|
||||
| `npm run verify` | Full pre-ship check: config gen, TS, shell syntax, build |
|
||||
| `sudo bash scripts/install-tor-onion.sh` | Install/refresh Tor + nginx config |
|
||||
| `sudo bash scripts/backup-onion-keys.sh` | Back up all hidden-service key dirs |
|
||||
| `sudo bash scripts/restore-onion-keys.sh` | Restore missing key dirs from backup |
|
||||
| `sudo bash scripts/install-systemd.sh` | Install `cyberlux.service` for boot-time start |
|
||||
| `sudo bash scripts/classroom-ufw.sh` | Lock firewall to loopback-only (recommended) |
|
||||
| `bash scripts/mkp224o-build.sh` | Build the mkp224o v3 vanity key generator into `vendor/mkp224o/` (gitignored) |
|
||||
| `bash scripts/vanity-onion-mine.sh <prefix>` | Brute-force a .onion starting with `prefix` (base32) → `var/vanity-mine/` |
|
||||
| `sudo bash scripts/install-vanity-onion-key.sh var/vanity-mine` | Install mined keys into `/var/lib/tor/cyberlux`, restart Tor |
|
||||
|
||||
**Build fails with `EACCES` on `unlink` / `build-diagnostics.json`:** Next.js writes metadata under `.next/diagnostics/build-diagnostics.json` (build stage, options — not an app bug). If those files were created as **root** (e.g. `sudo npm run build`), your user cannot overwrite them. **`npm run build` now stops in `prebuild`** with a clear error instead of a cryptic unlink message. Fix once:
|
||||
**Vanity .onion** (a custom prefix, e.g. the site name) is optional and CPU-heavy. Full steps: [`ONION-URLS.md`](ONION-URLS.md#vanity-onion-custom-prefix-eg-site-name-at-the-start).
|
||||
|
||||
---
|
||||
|
||||
## Onion URL Reference
|
||||
|
||||
See [`ONION-URLS.md`](ONION-URLS.md) — live address notes for the single CyberLux onion.
|
||||
|
||||
To export live `.onion` addresses after Tor is running:
|
||||
|
||||
```bash
|
||||
sudo bash scripts/export-onion-urls.sh
|
||||
# writes onion-urls.txt (gitignored — reflects runtime state)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Ships in the Box
|
||||
|
||||
- **1** Tor v3 hidden service
|
||||
- Loopback port `127.0.0.1:8080` (nginx vhost, never public)
|
||||
- Next.js bound only to `127.0.0.1:3000`
|
||||
- One `.onion` for the whole site — hub, forum, market, exchange, wiki, and directory paths
|
||||
- Persistent hidden-service identity keyed by `HiddenServiceDir` name
|
||||
- Self-healing backup / restore for onion keys
|
||||
- BTCPay Server integration (configurable) for on-chain + Lightning deposits
|
||||
- Systemd unit generator for unattended boot
|
||||
|
||||
---
|
||||
|
||||
## What `./start.sh` Does (in order)
|
||||
|
||||
1. Regenerates Tor / nginx / app route maps from `scripts/onion-nodes.json`
|
||||
2. Runs `git pull --ff-only` if repo has `.git` (non-fatal on failure)
|
||||
3. Runs `npm install`
|
||||
4. Repairs `.next` ownership if a root-owned build left it unwritable
|
||||
5. Runs a production build
|
||||
6. Restores backed-up onion keys if any hidden-service directories are missing
|
||||
7. Installs Tor + nginx config (only when it changed)
|
||||
8. Waits for the CyberLux onion hostname file
|
||||
9. Refreshes the onion key backup set
|
||||
10. Prints every live `.onion` URL
|
||||
11. Starts Next on `127.0.0.1:3000`
|
||||
|
||||
---
|
||||
|
||||
## Source of Truth
|
||||
|
||||
Everything — Tor config, nginx vhosts, app route maps — is generated from one file:
|
||||
|
||||
```
|
||||
scripts/onion-nodes.json
|
||||
```
|
||||
|
||||
Generated artifacts (do not edit manually):
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `tor/cyberlux-nodes.conf` | Tor `HiddenServiceDir` / `HiddenServicePort` blocks |
|
||||
| `lib/onionRoutes.generated.ts` | TypeScript route map used by `proxy.ts` |
|
||||
| `scripts/generated/tor-dirs.txt` | Newline-separated list of service dir names |
|
||||
| `scripts/generated/onion-labels.tsv` | Human-readable labels per service |
|
||||
| `scripts/generated/onion-port-range.txt` | Port min / max for scripts |
|
||||
|
||||
---
|
||||
|
||||
## Persistent Onion Addresses
|
||||
|
||||
Addresses are stable as long as:
|
||||
|
||||
- `torDir` names in `scripts/onion-nodes.json` do not change
|
||||
- `/var/lib/tor/<service-dir>/` is preserved
|
||||
- or the backup set can restore those directories
|
||||
|
||||
Normal app restarts, rebuilds, and standard Tor restarts **do not rotate addresses**.
|
||||
|
||||
Backup location: `/var/backups/cyberlux-onion-keys/current`
|
||||
|
||||
---
|
||||
|
||||
## BTCPay Server
|
||||
|
||||
The checkout flow supports BTCPay Server for on-chain + Lightning payments. Configure with environment variables (see `.env.example` or `DEPLOY.md`). The stack runs without BTCPay — the API returns `503` if not configured.
|
||||
|
||||
---
|
||||
|
||||
## Boot at Startup (systemd)
|
||||
|
||||
```bash
|
||||
sudo CYBERLUX_USER=$USER bash scripts/install-systemd.sh
|
||||
sudo systemctl enable --now tor.service nginx.service cyberlux.service
|
||||
```
|
||||
|
||||
Use `tor@default.service` instead of `tor.service` if your distro names it that way.
|
||||
|
||||
Tor + nginx must already be configured (`./start.sh` or `sudo bash scripts/install-tor-onion.sh` first).
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Checks: config generation, TypeScript compilation, shell syntax for all boot/install/backup/restore scripts, full Next.js production build.
|
||||
|
||||
---
|
||||
|
||||
## Common Fixes
|
||||
|
||||
**`.next` permission error (built with sudo):**
|
||||
|
||||
```bash
|
||||
sudo bash scripts/fix-next-perms.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
(or `sudo chown -R "$USER" .next` / `sudo rm -rf .next`), then `npm run build` or `./start.sh` again.
|
||||
**Onion hostnames not showing up:**
|
||||
|
||||
**Verify locally:** after fixing permissions, run `npm run verify` — regenerates onion configs, runs `tsc --noEmit`, checks shell scripts, and runs a production build.
|
||||
```bash
|
||||
sudo systemctl status tor
|
||||
sudo ls -la /var/lib/tor/
|
||||
# Wait ~30s after Tor starts for v3 descriptor publication
|
||||
```
|
||||
|
||||
**Security notes (for instructors):** No stack is “perfectly” secure. This setup keeps the app off the public internet: nginx and Next listen on loopback; Tor exposes onion endpoints only. Optional: `sudo bash scripts/classroom-ufw.sh` — do not publish `3000` or nginx loopback ports (`8080–8122`) to the public internet. Tor improves reachability privacy for the **service** vs raw IP hosting; it does not make unsafe application code safe or absolve lawful/ethical/use-policy obligations—use only for legitimate teaching and demos.
|
||||
**Port 3000 already in use:**
|
||||
|
||||
## Learn More
|
||||
```bash
|
||||
ss -ltnp | grep 3000
|
||||
# Stop the conflicting process, then ./start.sh
|
||||
```
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
---
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Security
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
- Next.js and nginx bind to **loopback only** — they are never public
|
||||
- Tor publishes the `.onion` endpoints
|
||||
- Do **not** expose ports 3000 or 8080 to the internet
|
||||
- Harden with: `sudo bash scripts/classroom-ufw.sh`
|
||||
- Tor obscures reachability — it does not forgive bad code or bad ops
|
||||
|
||||
## Deploy on Vercel
|
||||
Read `DEPLOY.md` before pointing real users at this stack.
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
---
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
## Site Map
|
||||
|
||||
| Vertical | Path | Onion |
|
||||
|---------|------|-------|
|
||||
| Master directory | `/directory` | `cyberlux` |
|
||||
| Hub storefront | `/` | `cyberlux` |
|
||||
| Forum (Void Aggregate) | `/forum` | `cyberlux` |
|
||||
| Exchange (classifieds) | `/exchange` | `cyberlux` |
|
||||
| Market (full catalog) | `/market` | `cyberlux` |
|
||||
| Barter (Ash Pit) | `/barter` | `cyberlux` |
|
||||
| Search (Void Crawler) | `/search` | `cyberlux` |
|
||||
| Hidden Wiki | `/hidden-wiki` | `cyberlux` |
|
||||
| Syndicate (shell net) | `/syndicate` | `cyberlux` |
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
- **Next.js 16** · React 19 · Tailwind CSS 4
|
||||
- **nginx** reverse proxy (loopback vhosts)
|
||||
- **Tor** v3 hidden services
|
||||
- **BTCPay Server** (optional, on-chain + Lightning)
|
||||
- Generated route / host mapping from single JSON source
|
||||
|
||||
---
|
||||
|
||||
> If you are running this, you are past cosplay.
|
||||
> The network does not owe you anonymity — **you** owe the network discipline.
|
||||
> Read `DEPLOY.md`. Own your jurisdiction.
|
||||
|
||||
19
SECURITY.md
Normal file
19
SECURITY.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
This repository is maintained for authorized, lawful work only. Do not use code, firmware, payloads, scripts, or documentation from this project against systems, accounts, devices, networks, cards, readers, or services you do not own or do not have explicit permission to test.
|
||||
|
||||
## Reporting
|
||||
|
||||
Report security concerns privately to the maintainer. Do not open public issues containing live credentials, tokens, private captures, card data, target identifiers, exploit chains, or sensitive logs.
|
||||
|
||||
## Secrets And Data
|
||||
|
||||
- Do not commit `.env` files, API keys, Wi-Fi credentials, session cookies, private keys, dumps, captures, or personal data.
|
||||
- Firmware binaries and captured artifacts must include provenance notes and SHA256 hashes before release.
|
||||
- Generated dependency folders and build output belong outside Git unless there is a documented reason.
|
||||
|
||||
## Maintainer Rule
|
||||
|
||||
If a change increases misuse risk, narrows safety boundaries, or weakens provenance, it must be rejected or quarantined until documented.
|
||||
193
app/academy/page.tsx
Normal file
193
app/academy/page.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
const COURSES = [
|
||||
{
|
||||
id: "opsec-foundations",
|
||||
title: "OPSEC Foundations",
|
||||
subtitle: "Zero-to-operational in 6 modules",
|
||||
icon: "🛡️",
|
||||
level: "Beginner",
|
||||
modules: 6,
|
||||
cost: 0,
|
||||
free: true,
|
||||
topics: ["Threat modelling", "Device isolation", "Handle hygiene", "Network compartmentalisation"],
|
||||
},
|
||||
{
|
||||
id: "tor-deep-dive",
|
||||
title: "Tor Architecture",
|
||||
subtitle: "How the onion network actually works",
|
||||
icon: "🌐",
|
||||
level: "Intermediate",
|
||||
modules: 8,
|
||||
cost: 15,
|
||||
free: false,
|
||||
topics: ["Circuit construction", "Hidden service descriptor flow", "Guard node selection", "Fingerprinting resistance"],
|
||||
},
|
||||
{
|
||||
id: "pgp-mastery",
|
||||
title: "PGP & Key Hygiene",
|
||||
subtitle: "Encrypt everything, trust nobody",
|
||||
icon: "🔐",
|
||||
level: "Intermediate",
|
||||
modules: 5,
|
||||
cost: 12,
|
||||
free: false,
|
||||
topics: ["Ed25519 vs RSA", "Web of trust vs TOFU", "Subkey architecture", "Revocation ceremonies"],
|
||||
},
|
||||
{
|
||||
id: "darknet-recon",
|
||||
title: "Darknet OSINT",
|
||||
subtitle: "Gather intelligence without leaving traces",
|
||||
icon: "🔍",
|
||||
level: "Advanced",
|
||||
modules: 10,
|
||||
cost: 25,
|
||||
free: false,
|
||||
topics: ["Passive recon techniques", "Link graph analysis", "Temporal correlation", "Attribution avoidance"],
|
||||
},
|
||||
{
|
||||
id: "btc-privacy",
|
||||
title: "Bitcoin Privacy",
|
||||
subtitle: "Spend and receive without a trail",
|
||||
icon: "₿",
|
||||
level: "Intermediate",
|
||||
modules: 7,
|
||||
cost: 20,
|
||||
free: false,
|
||||
topics: ["UTXO management", "CoinJoin & PayJoin", "Lightning privacy tradeoffs", "Blockchain analysis evasion"],
|
||||
},
|
||||
{
|
||||
id: "hidden-service-ops",
|
||||
title: "Hidden Service Operations",
|
||||
subtitle: "Deploy and harden a Tor v3 site",
|
||||
icon: "🧅",
|
||||
level: "Advanced",
|
||||
modules: 9,
|
||||
cost: 30,
|
||||
free: false,
|
||||
topics: ["Vanity address mining", "nginx hardening", "DoS mitigation", "Key backup & recovery", "Systemd hardening"],
|
||||
},
|
||||
];
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
Beginner: "text-neon-green border-neon-green/30 bg-neon-green/10",
|
||||
Intermediate: "text-neon-cyan border-neon-cyan/30 bg-neon-cyan/10",
|
||||
Advanced: "text-neon-purple border-neon-purple/30 bg-neon-purple/10",
|
||||
};
|
||||
|
||||
export default function AcademyPage() {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-[#0a0a0a] pt-24 pb-20">
|
||||
{/* Header */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-14 text-center">
|
||||
<p className="mb-3 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">cipher academy · operational security</p>
|
||||
<h1 className="font-orbitron text-5xl font-bold md:text-6xl">
|
||||
CIPHER<span className="text-neon-cyan"> ACADEMY</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-xl text-lg text-foreground/60">
|
||||
Structured OPSEC and darknet operations curriculum. Free foundation courses; advanced modules unlocked with VOID credits.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-12">
|
||||
<div className="flex flex-wrap justify-center gap-8 text-center">
|
||||
{[
|
||||
{ label: "Courses", value: COURSES.length },
|
||||
{ label: "Free modules", value: "6" },
|
||||
{ label: "Total modules", value: COURSES.reduce((a, c) => a + c.modules, 0) },
|
||||
{ label: "Skill levels", value: "3" },
|
||||
].map((s) => (
|
||||
<div key={s.label}>
|
||||
<p className="font-orbitron text-3xl font-black text-neon-cyan">{s.value}</p>
|
||||
<p className="mt-1 text-xs text-foreground/50 uppercase tracking-widest">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Course grid */}
|
||||
<section className="container mx-auto max-w-6xl px-4">
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{COURSES.map((course) => (
|
||||
<div
|
||||
key={course.id}
|
||||
className="rounded-xl border border-zinc-700/40 bg-black/40 p-5 flex flex-col hover:border-zinc-500 transition"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-3">
|
||||
<span className="text-3xl">{course.icon}</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider ${LEVEL_COLORS[course.level]}`}>
|
||||
{course.level}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-orbitron text-base font-bold">{course.title}</h3>
|
||||
<p className="mt-1 text-xs text-foreground/50">{course.subtitle}</p>
|
||||
<p className="mt-1 text-xs text-foreground/40">{course.modules} modules</p>
|
||||
|
||||
<ul className="mt-4 flex-1 space-y-1">
|
||||
{course.topics.map((t) => (
|
||||
<li key={t} className="text-xs text-foreground/60 flex gap-2">
|
||||
<span className="text-neon-cyan/60">›</span> {t}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
{course.free ? (
|
||||
<span className="rounded-full bg-neon-green/15 border border-neon-green/30 px-3 py-1 text-xs font-bold text-neon-green">
|
||||
FREE
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-sm font-bold text-neon-purple">✦ {course.cost} VOID</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(open === course.id ? null : course.id)}
|
||||
className="rounded-lg border border-zinc-600 bg-white/5 px-4 py-1.5 text-xs hover:border-zinc-400 transition"
|
||||
>
|
||||
{open === course.id ? "Collapse" : "Preview"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open === course.id && (
|
||||
<div className="mt-4 rounded-lg border border-zinc-700/40 bg-black/30 p-4">
|
||||
<p className="text-xs text-foreground/60 mb-3">Module breakdown coming soon. Sign in and unlock to access full curriculum.</p>
|
||||
<Link
|
||||
href={course.free ? "/sign-up" : "/account/add-funds"}
|
||||
className="block text-center rounded-lg bg-neon-cyan/15 border border-neon-cyan/30 px-4 py-2 text-xs font-bold text-neon-cyan hover:bg-neon-cyan/25"
|
||||
>
|
||||
{course.free ? "Start free →" : "Unlock with VOID credits →"}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="container mx-auto max-w-2xl px-4 mt-20 text-center">
|
||||
<div className="rounded-2xl border border-neon-purple/20 bg-neon-purple/5 p-10">
|
||||
<h2 className="font-orbitron text-2xl font-bold mb-3">Start with the free course</h2>
|
||||
<p className="text-foreground/60 mb-6">OPSEC Foundations is available to all registered users at no cost. No excuses.</p>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
<Link href="/sign-up" className="rounded-full border border-neon-cyan/40 bg-neon-cyan/10 px-6 py-2.5 text-sm font-bold text-neon-cyan hover:bg-neon-cyan/20">
|
||||
Register →
|
||||
</Link>
|
||||
<Link href="/account/add-funds" className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-2.5 text-sm font-bold text-background hover:opacity-90">
|
||||
+ VOID Credits
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
745
app/account/add-funds/page.tsx
Normal file
745
app/account/add-funds/page.tsx
Normal file
@@ -0,0 +1,745 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { useWallet } from "@/contexts/WalletContext";
|
||||
import { getMerchantBtcAddress, isMerchantBtcConfigured } from "@/lib/merchantBtc";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type Tab = "BTCPAY" | "BTC" | "ETH" | "XMR";
|
||||
|
||||
type BtcPayInvoiceLocal = {
|
||||
invoiceId: string;
|
||||
handle: string;
|
||||
usdAmount: number;
|
||||
btcAddress: string | null;
|
||||
btcAmount: string | null;
|
||||
btcRate: string | null;
|
||||
status: "New" | "Processing" | "Expired" | "Invalid" | "Settled";
|
||||
createdAt: number;
|
||||
credited: boolean;
|
||||
};
|
||||
|
||||
type ManualDeposit = {
|
||||
id: string;
|
||||
coin: "ETH" | "XMR";
|
||||
txid: string;
|
||||
ts: number;
|
||||
status: "pending" | "credited";
|
||||
ref?: string;
|
||||
};
|
||||
|
||||
// ─── localStorage helpers ───────────────────────────────────────────────────
|
||||
|
||||
const INVOICE_KEY = "cyberlux-btcpay-invoices-v1";
|
||||
const MANUAL_KEY = "cyberlux-pending-deposits-v1";
|
||||
|
||||
function loadInvoices(): BtcPayInvoiceLocal[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try { return JSON.parse(localStorage.getItem(INVOICE_KEY) ?? "[]") as BtcPayInvoiceLocal[]; }
|
||||
catch { return []; }
|
||||
}
|
||||
function saveInvoices(d: BtcPayInvoiceLocal[]) {
|
||||
if (typeof window !== "undefined") localStorage.setItem(INVOICE_KEY, JSON.stringify(d));
|
||||
}
|
||||
function loadManual(): ManualDeposit[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try { return JSON.parse(localStorage.getItem(MANUAL_KEY) ?? "[]") as ManualDeposit[]; }
|
||||
catch { return []; }
|
||||
}
|
||||
function saveManual(d: ManualDeposit[]) {
|
||||
if (typeof window !== "undefined") localStorage.setItem(MANUAL_KEY, JSON.stringify(d));
|
||||
}
|
||||
function shortId() { return Math.random().toString(36).slice(2, 10).toUpperCase(); }
|
||||
|
||||
// ─── Style maps ────────────────────────────────────────────────────────────
|
||||
|
||||
const TAB_COLOR: Record<Tab, string> = {
|
||||
BTCPAY: "text-neon-purple",
|
||||
BTC: "text-amber-400",
|
||||
ETH: "text-blue-400",
|
||||
XMR: "text-orange-400",
|
||||
};
|
||||
const TAB_BORDER: Record<Tab, string> = {
|
||||
BTCPAY: "border-neon-purple/30",
|
||||
BTC: "border-amber-500/30",
|
||||
ETH: "border-blue-500/30",
|
||||
XMR: "border-orange-500/30",
|
||||
};
|
||||
const TAB_BG: Record<Tab, string> = {
|
||||
BTCPAY: "bg-neon-purple/10",
|
||||
BTC: "bg-amber-500/10",
|
||||
ETH: "bg-blue-500/10",
|
||||
XMR: "bg-orange-500/10",
|
||||
};
|
||||
|
||||
// ─── Component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddFundsPage() {
|
||||
const router = useRouter();
|
||||
const { user, hydrated } = useAccount();
|
||||
const { usdStoreCredit, luxCredits, verifyBtcDeposit, creditBtcPayInvoice } = useWallet();
|
||||
|
||||
const btcPayEnabled = (process.env.NEXT_PUBLIC_BTCPAY_ENABLED ?? "").toLowerCase() === "true";
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>(btcPayEnabled ? "BTCPAY" : "BTC");
|
||||
const [copied, setCopied] = useState<Tab | null>(null);
|
||||
|
||||
// ── BTCPay state ──────────────────────────────────────────────────────────
|
||||
const [desiredUsd, setDesiredUsd] = useState("25");
|
||||
const [bpBusy, setBpBusy] = useState(false);
|
||||
const [bpError, setBpError] = useState<string | null>(null);
|
||||
const [activeInvoice, setActiveInvoice] = useState<BtcPayInvoiceLocal | null>(null);
|
||||
const [invoiceHistory, setInvoiceHistory] = useState<BtcPayInvoiceLocal[]>([]);
|
||||
const [bpSuccess, setBpSuccess] = useState<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ── Manual BTC verify state ───────────────────────────────────────────────
|
||||
const [btcTxid, setBtcTxid] = useState("");
|
||||
const [btcBusy, setBtcBusy] = useState(false);
|
||||
const [btcMsg, setBtcMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
// ── ETH / XMR manual state ────────────────────────────────────────────────
|
||||
const [manualTxid, setManualTxid] = useState("");
|
||||
const [manualBusy, setManualBusy] = useState(false);
|
||||
const [manualMsg, setManualMsg] = useState<{ kind: "ok" | "err" | "pending"; text: string; ref?: string } | null>(null);
|
||||
const [manualHistory, setManualHistory] = useState<ManualDeposit[]>([]);
|
||||
|
||||
const btcAddr = getMerchantBtcAddress();
|
||||
const btcReady = isMerchantBtcConfigured();
|
||||
const ethAddr = (process.env.NEXT_PUBLIC_ETH_ADDRESS || "").trim();
|
||||
const xmrAddr = (process.env.NEXT_PUBLIC_XMR_ADDRESS || "").trim();
|
||||
|
||||
// ── Auth guard ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (hydrated && !user) router.replace("/sign-in?next=/account/add-funds");
|
||||
}, [hydrated, user, router]);
|
||||
|
||||
// ── Load local state ──────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setInvoiceHistory(loadInvoices());
|
||||
setManualHistory(loadManual());
|
||||
}, []);
|
||||
|
||||
// ── Poll active BTCPay invoice ────────────────────────────────────────────
|
||||
const stopPoll = useCallback(() => {
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
}, []);
|
||||
|
||||
const pollInvoice = useCallback(
|
||||
async (invoice: BtcPayInvoiceLocal) => {
|
||||
if (invoice.credited || invoice.status === "Settled" || invoice.status === "Expired" || invoice.status === "Invalid") {
|
||||
stopPoll();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/btcpay/status/${invoice.invoiceId}`);
|
||||
const data = (await res.json()) as {
|
||||
ok: boolean;
|
||||
status?: string;
|
||||
usdAmount?: number;
|
||||
btcAddress?: string | null;
|
||||
btcAmount?: string | null;
|
||||
btcRate?: string | null;
|
||||
settled?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
if (!data.ok) return;
|
||||
|
||||
const newStatus = (data.status ?? invoice.status) as BtcPayInvoiceLocal["status"];
|
||||
|
||||
// Update address/amount if BTCPay just returned them (first poll after creation)
|
||||
const updated: BtcPayInvoiceLocal = {
|
||||
...invoice,
|
||||
status: newStatus,
|
||||
btcAddress: data.btcAddress ?? invoice.btcAddress,
|
||||
btcAmount: data.btcAmount ?? invoice.btcAmount,
|
||||
btcRate: data.btcRate ?? invoice.btcRate,
|
||||
credited: invoice.credited,
|
||||
};
|
||||
|
||||
if (data.settled && !invoice.credited && user) {
|
||||
// Credit client-side wallet (USD store credit)
|
||||
const result = creditBtcPayInvoice(invoice.invoiceId, invoice.usdAmount);
|
||||
if (result.ok) {
|
||||
updated.credited = true;
|
||||
setBpSuccess(`✓ $${invoice.usdAmount.toFixed(2)} USD credited to @${user.username}!`);
|
||||
}
|
||||
// Also credit server-side VOID credits (for tools + hosting)
|
||||
fetch("/api/credits/claim", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ invoiceId: invoice.invoiceId, handle: user.username }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d: { ok: boolean; voidCredited?: number }) => {
|
||||
if (d.ok && d.voidCredited) {
|
||||
setBpSuccess((prev) =>
|
||||
(prev ?? "") + ` ✦ ${d.voidCredited} VOID credits added to tool vault.`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => void 0);
|
||||
stopPoll();
|
||||
}
|
||||
|
||||
if (newStatus === "Expired" || newStatus === "Invalid") stopPoll();
|
||||
|
||||
setActiveInvoice(updated);
|
||||
setInvoiceHistory((prev) => {
|
||||
const next = prev.map((inv) => inv.invoiceId === updated.invoiceId ? updated : inv);
|
||||
if (!next.find((i) => i.invoiceId === updated.invoiceId)) next.unshift(updated);
|
||||
saveInvoices(next);
|
||||
return next;
|
||||
});
|
||||
} catch { /* ignore network blips */ }
|
||||
},
|
||||
[user, creditBtcPayInvoice, stopPoll],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeInvoice) { stopPoll(); return; }
|
||||
if (activeInvoice.credited || activeInvoice.status === "Settled" || activeInvoice.status === "Expired") return;
|
||||
stopPoll();
|
||||
pollRef.current = setInterval(() => { void pollInvoice(activeInvoice); }, 10_000);
|
||||
// also poll immediately to fetch btcAddress on first render
|
||||
void pollInvoice(activeInvoice);
|
||||
return stopPoll;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeInvoice?.invoiceId]);
|
||||
|
||||
useEffect(() => { return stopPoll; }, [stopPoll]);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
const createInvoice = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
const usd = parseFloat(desiredUsd);
|
||||
if (!Number.isFinite(usd) || usd < 0.5) { setBpError("Enter an amount ≥ $0.50"); return; }
|
||||
setBpBusy(true);
|
||||
setBpError(null);
|
||||
setBpSuccess(null);
|
||||
try {
|
||||
const res = await fetch("/api/btcpay/invoice", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ usdAmount: usd, handle: user.username }),
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
ok: boolean;
|
||||
invoiceId?: string;
|
||||
checkoutLink?: string;
|
||||
status?: string;
|
||||
expiresAt?: number;
|
||||
error?: string;
|
||||
};
|
||||
if (!data.ok || !data.invoiceId) {
|
||||
setBpError(data.error ?? "Failed to create invoice. Check BTCPay config.");
|
||||
return;
|
||||
}
|
||||
const newInv: BtcPayInvoiceLocal = {
|
||||
invoiceId: data.invoiceId,
|
||||
handle: user.username,
|
||||
usdAmount: usd,
|
||||
btcAddress: null,
|
||||
btcAmount: null,
|
||||
btcRate: null,
|
||||
status: (data.status ?? "New") as BtcPayInvoiceLocal["status"],
|
||||
createdAt: Date.now(),
|
||||
credited: false,
|
||||
};
|
||||
setActiveInvoice(newInv);
|
||||
setInvoiceHistory((prev) => { const n = [newInv, ...prev]; saveInvoices(n); return n; });
|
||||
} catch {
|
||||
setBpError("Network error. Is BTCPay reachable?");
|
||||
} finally {
|
||||
setBpBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyText = async (text: string, tab: Tab) => {
|
||||
try { await navigator.clipboard.writeText(text); setCopied(tab); setTimeout(() => setCopied(null), 1800); }
|
||||
catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const onVerifyBtc = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!btcTxid.trim()) return;
|
||||
setBtcBusy(true); setBtcMsg(null);
|
||||
try {
|
||||
const res = await verifyBtcDeposit(btcTxid.trim());
|
||||
if (res.ok) {
|
||||
setBtcMsg({ kind: "ok", text: `✓ $${(res.creditedUsd ?? 0).toFixed(2)} USD credited to @${user!.username}.` });
|
||||
setBtcTxid("");
|
||||
} else {
|
||||
setBtcMsg({ kind: "err", text: res.error ?? "Verification failed." });
|
||||
}
|
||||
} finally { setBtcBusy(false); }
|
||||
};
|
||||
|
||||
const onSubmitManual = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!manualTxid.trim() || !user || (activeTab !== "ETH" && activeTab !== "XMR")) return;
|
||||
setManualBusy(true); setManualMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/pending-deposit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ coin: activeTab, txid: manualTxid.trim(), handle: user.username }),
|
||||
});
|
||||
const json = (await res.json()) as { ok: boolean; message?: string; ref?: string; error?: string };
|
||||
if (json.ok) {
|
||||
const entry: ManualDeposit = {
|
||||
id: shortId(), coin: activeTab as "ETH" | "XMR",
|
||||
txid: manualTxid.trim(), ts: Date.now(), status: "pending", ref: json.ref,
|
||||
};
|
||||
setManualHistory((prev) => { const n = [entry, ...prev]; saveManual(n); return n; });
|
||||
setManualMsg({ kind: "pending", text: json.message ?? "Submitted for manual review.", ref: json.ref });
|
||||
setManualTxid("");
|
||||
} else {
|
||||
setManualMsg({ kind: "err", text: json.error ?? "Submission failed." });
|
||||
}
|
||||
} catch {
|
||||
setManualMsg({ kind: "err", text: "Network error." });
|
||||
} finally { setManualBusy(false); }
|
||||
};
|
||||
|
||||
// ── Loading state ─────────────────────────────────────────────────────────
|
||||
if (!hydrated || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#0a0a0a] font-mono text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: Tab[] = btcPayEnabled ? ["BTCPAY", "BTC", "ETH", "XMR"] : ["BTC", "ETH", "XMR"];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-zinc-200">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-3xl px-4 py-28">
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">
|
||||
@{user.username} · deposit desk
|
||||
</p>
|
||||
<h1 className="mt-2 font-orbitron text-4xl font-bold">Fund Your Account</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm text-zinc-400 leading-relaxed">
|
||||
Send crypto to the address below. We verify the transaction on-chain, convert to USD at current
|
||||
market rate, and credit your account.{" "}
|
||||
<strong className="text-zinc-200">You no longer hold the crypto</strong> — we do.
|
||||
When you purchase, we pay the vendor in crypto on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* How it works */}
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{[
|
||||
{ n: "1", icon: "₿", title: "Send crypto", body: "BTC via BTCPay, or BTC/ETH/XMR to a static address." },
|
||||
{ n: "2", icon: "📊", title: "We verify", body: "On-chain confirmation. Credited at live market USD rate." },
|
||||
{ n: "3", icon: "🛒", title: "You shop", body: "Spend USD credits site-wide. We pay vendors in crypto." },
|
||||
].map((s) => (
|
||||
<div key={s.n} className="glass rounded-2xl border border-white/10 p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-neon-cyan/15 text-sm font-bold text-neon-cyan">
|
||||
{s.n}
|
||||
</div>
|
||||
<span className="text-xl">{s.icon}</span>
|
||||
</div>
|
||||
<div className="font-bold text-zinc-100">{s.title}</div>
|
||||
<p className="mt-1 text-xs text-zinc-500">{s.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Balance card */}
|
||||
<div className="glass mb-8 rounded-2xl border border-neon-cyan/20 p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">Current balance — @{user.username}</div>
|
||||
<div className="mt-1 font-orbitron text-3xl text-neon-cyan">${usdStoreCredit.toFixed(2)} USD</div>
|
||||
<div className="mt-0.5 text-sm text-neon-green">{luxCredits.toLocaleString()} LUX</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/checkout" className="rounded-lg border border-neon-cyan/30 px-4 py-2 text-sm text-neon-cyan hover:bg-neon-cyan/10">
|
||||
Spend →
|
||||
</Link>
|
||||
<Link href="/wallets" className="rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:border-white/20">
|
||||
Passes
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab selector */}
|
||||
<div className="mb-6 flex gap-2 flex-wrap">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => { setActiveTab(tab); setBpError(null); setBtcMsg(null); setManualMsg(null); }}
|
||||
className={`flex-1 min-w-[80px] rounded-xl border py-3 text-xs font-bold uppercase transition-all ${
|
||||
activeTab === tab
|
||||
? `${TAB_BORDER[tab]} ${TAB_BG[tab]} ${TAB_COLOR[tab]}`
|
||||
: "border-white/10 text-zinc-500 hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
{tab === "BTCPAY" ? "BTCPay ✦" : tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── BTCPAY TAB ─────────────────────────────────────────────────── */}
|
||||
{activeTab === "BTCPAY" && (
|
||||
<div className="space-y-5">
|
||||
<div className="glass rounded-2xl border border-neon-purple/30 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className="font-orbitron text-lg text-neon-purple">BTCPay Invoice</h2>
|
||||
<span className="rounded-full bg-neon-green/15 px-3 py-1 text-xs font-bold text-neon-green">
|
||||
Auto-settle
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-5 text-xs text-zinc-500">
|
||||
Generates a unique Bitcoin address per deposit. BTCPay monitors the chain — your account is
|
||||
credited automatically the moment the payment confirms. No txid entry needed.
|
||||
</p>
|
||||
|
||||
{!activeInvoice || activeInvoice.status === "Expired" || activeInvoice.status === "Invalid" ? (
|
||||
<form onSubmit={(e) => void createInvoice(e)} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs text-zinc-500 uppercase tracking-wider">
|
||||
Amount to deposit (USD)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{["10", "25", "50", "100", "250"].map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => setDesiredUsd(v)}
|
||||
className={`rounded-lg border px-3 py-1.5 text-xs font-bold transition-all ${
|
||||
desiredUsd === v
|
||||
? "border-neon-purple/50 bg-neon-purple/15 text-neon-purple"
|
||||
: "border-white/10 text-zinc-500 hover:border-white/20"
|
||||
}`}
|
||||
>
|
||||
${v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min="0.50"
|
||||
step="0.01"
|
||||
value={desiredUsd}
|
||||
onChange={(e) => setDesiredUsd(e.target.value)}
|
||||
className="mt-3 w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm placeholder-zinc-600 focus:border-neon-purple/40 focus:outline-none"
|
||||
placeholder="Custom amount"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={bpBusy}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-neon-purple to-neon-pink py-3 text-sm font-bold text-white disabled:opacity-40"
|
||||
>
|
||||
{bpBusy ? "Creating invoice…" : "Generate Payment Invoice →"}
|
||||
</button>
|
||||
{bpError && (
|
||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{bpError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
/* Active invoice */
|
||||
<div className="space-y-4">
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-bold uppercase ${
|
||||
activeInvoice.status === "Settled"
|
||||
? "bg-neon-green/20 text-neon-green"
|
||||
: activeInvoice.status === "Processing"
|
||||
? "bg-amber-500/20 text-amber-400"
|
||||
: "bg-neon-purple/20 text-neon-purple"
|
||||
}`}>
|
||||
{activeInvoice.status === "New" ? "Waiting for payment…" : activeInvoice.status}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
${activeInvoice.usdAmount.toFixed(2)} USD
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* BTC address (fetched from payment-methods on first poll) */}
|
||||
{activeInvoice.btcAddress ? (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-zinc-500 uppercase tracking-wider">Send exactly</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 rounded-xl border border-amber-500/30 bg-black/60 px-4 py-3 font-mono text-sm text-amber-400 break-all">
|
||||
{activeInvoice.btcAmount ?? "…"} BTC
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 mb-1 text-xs text-zinc-500 uppercase tracking-wider">To this address</div>
|
||||
<div className="rounded-xl border border-amber-500/30 bg-black/60 px-4 py-3 font-mono text-xs text-amber-400 break-all">
|
||||
{activeInvoice.btcAddress}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(activeInvoice.btcAddress!, "BTC")}
|
||||
className="mt-2 w-full rounded-xl border border-amber-500/30 bg-amber-500/10 py-2.5 text-xs font-bold text-amber-400 uppercase hover:opacity-90"
|
||||
>
|
||||
{copied === "BTC" ? "✓ Copied" : "Copy BTC Address"}
|
||||
</button>
|
||||
{activeInvoice.btcRate && (
|
||||
<p className="mt-2 text-[11px] text-zinc-600">
|
||||
Rate locked at 1 BTC ≈ ${parseFloat(activeInvoice.btcRate).toLocaleString()} USD
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-pulse text-xs text-zinc-500">Fetching payment address…</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{(bpSuccess || activeInvoice.credited) && (
|
||||
<div className="rounded-xl border border-neon-green/40 bg-neon-green/10 p-4 text-sm font-bold text-neon-green">
|
||||
{bpSuccess ?? `✓ $${activeInvoice.usdAmount.toFixed(2)} USD credited to @${user.username}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!activeInvoice.credited && activeInvoice.status !== "Settled" && (
|
||||
<p className="text-[11px] text-zinc-600">
|
||||
Polling for confirmation every 10 s. Do not close this tab until confirmed.
|
||||
This invoice is valid for 60 minutes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setActiveInvoice(null); setBpSuccess(null); setBpError(null); }}
|
||||
className="mt-2 rounded-xl border border-white/10 px-5 py-2 text-xs text-zinc-500 hover:border-white/20"
|
||||
>
|
||||
← New invoice
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Invoice history */}
|
||||
{invoiceHistory.filter((i) => i.handle === user.username).length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-mono text-xs uppercase tracking-wider text-zinc-600">Invoice history</h3>
|
||||
<div className="space-y-2">
|
||||
{invoiceHistory.filter((i) => i.handle === user.username).map((inv) => (
|
||||
<div key={inv.invoiceId} className="glass flex items-center justify-between gap-4 rounded-xl border border-white/10 px-4 py-3 text-xs">
|
||||
<div className="font-mono text-zinc-400">
|
||||
{inv.invoiceId.slice(0, 12)}… · ${inv.usdAmount.toFixed(2)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase ${
|
||||
inv.credited ? "bg-neon-green/20 text-neon-green"
|
||||
: inv.status === "Processing" ? "bg-amber-500/20 text-amber-400"
|
||||
: inv.status === "Expired" ? "bg-red-500/20 text-red-400"
|
||||
: "bg-zinc-500/20 text-zinc-400"
|
||||
}`}>
|
||||
{inv.credited ? "credited" : inv.status.toLowerCase()}
|
||||
</span>
|
||||
<span className="text-zinc-600">{new Date(inv.createdAt).toLocaleDateString()}</span>
|
||||
{!inv.credited && inv.status !== "Expired" && inv.status !== "Invalid" && inv.status !== "Settled" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveInvoice(inv)}
|
||||
className="rounded border border-neon-purple/30 px-2 py-0.5 text-[10px] text-neon-purple hover:bg-neon-purple/10"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── BTC TAB (manual txid verify) ──────────────────────────────── */}
|
||||
{activeTab === "BTC" && (
|
||||
<div className="space-y-5">
|
||||
<div className="glass rounded-2xl border border-amber-500/30 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className="font-orbitron text-lg text-amber-400">BTC — Manual Verify</h2>
|
||||
<span className="rounded-full bg-neon-green/15 px-3 py-1 text-xs font-bold text-neon-green">Auto-credit</span>
|
||||
</div>
|
||||
{!btcReady ? (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-300">
|
||||
<p className="font-bold">Address not configured</p>
|
||||
<p className="mt-1 text-amber-400/70">
|
||||
Set <code className="rounded bg-black/40 px-1">NEXT_PUBLIC_MERCHANT_BTC_ADDRESS</code> in{" "}
|
||||
<code className="rounded bg-black/40 px-1">.env.local</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-zinc-500">
|
||||
Send BTC to the static address below, then paste the 64-character transaction ID.
|
||||
We verify via mempool.space and credit instantly.
|
||||
</p>
|
||||
<div className="mb-2 rounded-xl border border-amber-500/30 bg-black/60 px-4 py-3 font-mono text-xs text-amber-400 break-all">
|
||||
{btcAddr}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(btcAddr, "BTC")}
|
||||
className="mb-5 w-full rounded-xl border border-amber-500/30 bg-amber-500/10 py-2.5 text-xs font-bold text-amber-400 uppercase hover:opacity-90"
|
||||
>
|
||||
{copied === "BTC" ? "✓ Copied" : "Copy BTC Address"}
|
||||
</button>
|
||||
<form onSubmit={(e) => void onVerifyBtc(e)} className="space-y-3">
|
||||
<input
|
||||
value={btcTxid}
|
||||
onChange={(e) => setBtcTxid(e.target.value.trim())}
|
||||
placeholder="Paste 64-character Bitcoin txid"
|
||||
className="w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-xs placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none"
|
||||
maxLength={64}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={btcBusy || !btcTxid.trim()}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 py-3 text-sm font-bold text-black disabled:opacity-40"
|
||||
>
|
||||
{btcBusy ? "Verifying…" : "Verify & Credit Now"}
|
||||
</button>
|
||||
</form>
|
||||
{btcMsg && (
|
||||
<div className={`mt-4 rounded-xl border p-3 text-sm ${btcMsg.kind === "ok" ? "border-neon-green/40 bg-neon-green/10 text-neon-green" : "border-red-500/40 bg-red-500/10 text-red-400"}`}>
|
||||
{btcMsg.text}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ETH / XMR TAB (manual review) ─────────────────────────────── */}
|
||||
{(activeTab === "ETH" || activeTab === "XMR") && (() => {
|
||||
const addr = activeTab === "ETH" ? ethAddr : xmrAddr;
|
||||
const addrReady = Boolean(addr);
|
||||
const envVar = activeTab === "ETH" ? "NEXT_PUBLIC_ETH_ADDRESS" : "NEXT_PUBLIC_XMR_ADDRESS";
|
||||
const note = activeTab === "ETH"
|
||||
? "Send ERC-20 or native ETH · submit txhash below · credited within 2–24h after manual review"
|
||||
: "Monero transactions take ~20 min · submit txid below · credited within 2–24h after manual review";
|
||||
return (
|
||||
<div className="glass rounded-2xl border p-6" style={{borderColor: activeTab === "ETH" ? "rgba(59,130,246,0.3)" : "rgba(249,115,22,0.3)"}}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h2 className={`font-orbitron text-lg ${TAB_COLOR[activeTab]}`}>{activeTab} Deposit</h2>
|
||||
<span className="rounded-full bg-amber-500/15 px-3 py-1 text-xs font-bold text-amber-400">Manual review</span>
|
||||
</div>
|
||||
{!addrReady ? (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-4 text-sm text-amber-300">
|
||||
<p className="font-bold">Address not configured</p>
|
||||
<p className="mt-1 text-amber-400/70">
|
||||
Set <code className="rounded bg-black/40 px-1">{envVar}</code> in{" "}
|
||||
<code className="rounded bg-black/40 px-1">.env.local</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-4 text-xs text-zinc-500">{note}</p>
|
||||
<div className={`mb-2 rounded-xl border bg-black/60 px-4 py-3 font-mono text-xs break-all ${TAB_BORDER[activeTab]} ${TAB_COLOR[activeTab]}`}>
|
||||
{addr}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(addr, activeTab)}
|
||||
className={`mb-5 w-full rounded-xl border py-2.5 text-xs font-bold uppercase hover:opacity-90 ${TAB_BORDER[activeTab]} ${TAB_BG[activeTab]} ${TAB_COLOR[activeTab]}`}
|
||||
>
|
||||
{copied === activeTab ? `✓ Copied` : `Copy ${activeTab} Address`}
|
||||
</button>
|
||||
<form onSubmit={(e) => void onSubmitManual(e)} className="space-y-3">
|
||||
<input
|
||||
value={manualTxid}
|
||||
onChange={(e) => setManualTxid(e.target.value.trim())}
|
||||
placeholder={activeTab === "ETH" ? "Ethereum txhash — 0x…" : "Monero transaction ID"}
|
||||
className="w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-xs placeholder-zinc-600 focus:outline-none"
|
||||
maxLength={100}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={manualBusy || !manualTxid.trim()}
|
||||
className={`w-full rounded-xl py-3 text-sm font-bold text-white disabled:opacity-40 bg-gradient-to-r ${activeTab === "ETH" ? "from-blue-600 to-blue-400" : "from-orange-600 to-orange-400"}`}
|
||||
>
|
||||
{manualBusy ? "Submitting…" : `Submit ${activeTab} for Review`}
|
||||
</button>
|
||||
</form>
|
||||
{manualMsg && (
|
||||
<div className={`mt-4 rounded-xl border p-3 text-sm ${manualMsg.kind === "ok" ? "border-neon-green/40 bg-neon-green/10 text-neon-green" : manualMsg.kind === "pending" ? "border-amber-500/40 bg-amber-500/10 text-amber-300" : "border-red-500/40 bg-red-500/10 text-red-400"}`}>
|
||||
<p>{manualMsg.text}</p>
|
||||
{manualMsg.ref && <p className="mt-1 font-mono text-xs opacity-70">Ref: {manualMsg.ref}</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Manual deposit history (ETH/XMR) */}
|
||||
{manualHistory.length > 0 && (activeTab === "ETH" || activeTab === "XMR") && (
|
||||
<div className="mt-6">
|
||||
<h3 className="mb-3 font-mono text-xs uppercase tracking-wider text-zinc-600">Manual review history</h3>
|
||||
<div className="space-y-2">
|
||||
{manualHistory.filter((d) => d.coin === activeTab).map((d) => (
|
||||
<div key={d.id} className="glass flex items-center justify-between gap-4 rounded-xl border border-white/10 px-4 py-3 text-xs">
|
||||
<div>
|
||||
<span className={`font-bold ${TAB_COLOR[d.coin]}`}>{d.coin}</span>
|
||||
<span className="ml-2 font-mono text-zinc-500">{d.txid.slice(0, 12)}…{d.txid.slice(-6)}</span>
|
||||
{d.ref && <span className="ml-2 text-[10px] text-zinc-600">ref: {d.ref}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase ${d.status === "credited" ? "bg-neon-green/20 text-neon-green" : "bg-amber-500/20 text-amber-400"}`}>
|
||||
{d.status}
|
||||
</span>
|
||||
<span className="text-zinc-600">{new Date(d.ts).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Important notes */}
|
||||
<div className="mt-10 rounded-2xl border border-white/10 bg-white/[0.02] p-6 text-sm">
|
||||
<div className="mb-3 font-bold text-zinc-200">Important — Read Before Depositing</div>
|
||||
<ul className="space-y-2 text-zinc-500">
|
||||
{[
|
||||
"Always verify the receiving address on each new session before sending.",
|
||||
"BTCPay invoices expire after 60 minutes. Static BTC deposits are valid until a new address is published.",
|
||||
"BTC (BTCPay) credits automatically on settlement. Manual ETH/XMR review takes 2–24h.",
|
||||
"Deposited funds become USD store credit. No withdrawals — credits are for purchases only.",
|
||||
"Export your account bundle from /dashboard before clearing browser data.",
|
||||
].map((note, i) => (
|
||||
<li key={i} className="flex items-start gap-2">
|
||||
<span className="mt-0.5 shrink-0 text-neon-cyan">→</span>
|
||||
{note}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-4 text-xs text-zinc-600">
|
||||
<Link href="/dashboard" className="text-neon-cyan hover:underline">Dashboard</Link>
|
||||
<Link href="/checkout" className="hover:text-zinc-400">Checkout</Link>
|
||||
<Link href="/wallets" className="hover:text-zinc-400">Digital Passes</Link>
|
||||
<Link href="/" className="hover:text-zinc-400">Hub</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
app/account/hidden-services/page.tsx
Normal file
165
app/account/hidden-services/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { exportPortableAccountJson, importPortableAccountJson } from "@/lib/cyberluxAccount";
|
||||
|
||||
export default function HiddenServicesAccountPage() {
|
||||
const { user, hydrated, refreshProfile } = useAccount();
|
||||
const [importText, setImportText] = useState("");
|
||||
const [importMsg, setImportMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
const [copyMsg, setCopyMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setImportMsg(null);
|
||||
}, [importText]);
|
||||
|
||||
const onCopyBundle = async () => {
|
||||
setCopyMsg(null);
|
||||
const json = exportPortableAccountJson();
|
||||
if (!json) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(json);
|
||||
setCopyMsg("Copied. Paste on another onion host → Import below.");
|
||||
} catch {
|
||||
setCopyMsg("Could not use clipboard — select and copy manually.");
|
||||
}
|
||||
};
|
||||
|
||||
const onImport = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setImportMsg(null);
|
||||
const res = importPortableAccountJson(importText.trim());
|
||||
if (!res.ok) {
|
||||
setImportMsg({ kind: "err", text: res.error });
|
||||
return;
|
||||
}
|
||||
refreshProfile();
|
||||
setImportText("");
|
||||
setImportMsg({ kind: "ok", text: "Identity loaded on this hostname. You can use dashboard, forum, exchange, and barter here." });
|
||||
};
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center font-mono text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] px-4 py-14 text-zinc-200">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-emerald-500/80">hidden services</p>
|
||||
<h1 className="mt-3 font-orbitron text-2xl font-bold text-zinc-100">Account on every entry node</h1>
|
||||
<p className="mt-4 text-sm leading-relaxed text-zinc-500">
|
||||
Each hidden-service hostname gets an isolated browser vault. Keys you mint on the forum onion do not
|
||||
automatically exist on the exchange or wiki host — mirror your identity deliberately or enroll separately.
|
||||
Verified Bitcoin USD credits are stored the same way: per host. Use a portable bundle to reuse your handle,
|
||||
then add funds on each hostname you actively use (
|
||||
<Link href="/account/add-funds" className="text-emerald-400 hover:underline">
|
||||
Add funds
|
||||
</Link>
|
||||
).
|
||||
</p>
|
||||
|
||||
<ol className="mt-8 list-decimal space-y-4 border-l border-white/10 pl-5 text-sm text-zinc-400">
|
||||
<li>
|
||||
<strong className="text-zinc-300">Create a handle</strong> on this host:{" "}
|
||||
<Link href="/sign-up" className="text-emerald-400 hover:underline">
|
||||
Sign up
|
||||
</Link>{" "}
|
||||
(or{" "}
|
||||
<Link href="/sign-in" className="text-emerald-400 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-zinc-300">Repeat on other mirrors</strong> with the{" "}
|
||||
<strong className="text-zinc-300">same handle and passphrase</strong>,{" "}
|
||||
<span className="text-zinc-500">or</span> export a bundle here (signed in) and import it on the other host.
|
||||
</li>
|
||||
<li>
|
||||
Use{" "}
|
||||
<Link href="/dashboard" className="text-emerald-400 hover:underline">
|
||||
Dashboard
|
||||
</Link>
|
||||
,{" "}
|
||||
<Link href="/forum" className="text-emerald-400 hover:underline">
|
||||
Forum
|
||||
</Link>
|
||||
,{" "}
|
||||
<Link href="/exchange" className="text-emerald-400 hover:underline">
|
||||
Exchange
|
||||
</Link>
|
||||
, and{" "}
|
||||
<Link href="/barter" className="text-emerald-400 hover:underline">
|
||||
Barter
|
||||
</Link>{" "}
|
||||
once this hostname knows your session.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{user ? (
|
||||
<section className="glass mt-10 rounded-2xl border border-emerald-900/40 p-6">
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-emerald-400/90">Portable bundle</h2>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
Signed in as <span className="font-mono text-emerald-200/90">@{user.username}</span>. The bundle contains a
|
||||
passphrase fingerprint — do not share it.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onCopyBundle()}
|
||||
className="mt-4 w-full rounded-xl border border-emerald-700/50 bg-emerald-950/40 py-3 font-mono text-sm font-bold text-emerald-200 hover:bg-emerald-900/30"
|
||||
>
|
||||
Copy identity bundle
|
||||
</button>
|
||||
{copyMsg ? <p className="mt-3 text-xs text-zinc-400">{copyMsg}</p> : null}
|
||||
</section>
|
||||
) : (
|
||||
<p className="mt-10 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-center text-sm text-zinc-500">
|
||||
<Link href="/sign-in" className="text-emerald-400 hover:underline">
|
||||
Sign in
|
||||
</Link>{" "}
|
||||
to copy an export bundle for another host.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className="glass mt-8 rounded-2xl border border-white/10 p-6">
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-zinc-400">Import bundle</h2>
|
||||
<p className="mt-2 text-xs text-zinc-500">Paste JSON from "Copy identity bundle" on the host where you exported it.</p>
|
||||
<form onSubmit={onImport} className="mt-4 space-y-3">
|
||||
<textarea
|
||||
value={importText}
|
||||
onChange={(e) => setImportText(e.target.value)}
|
||||
rows={5}
|
||||
placeholder='{"v":1,"username":"…", …}'
|
||||
className="glass w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2 font-mono text-[11px] text-zinc-300"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-xl bg-gradient-to-r from-emerald-800 to-emerald-700 py-3 font-bold text-white hover:opacity-95"
|
||||
>
|
||||
Import & sign in here
|
||||
</button>
|
||||
</form>
|
||||
{importMsg ? (
|
||||
<p
|
||||
className={`mt-3 text-sm ${importMsg.kind === "ok" ? "text-emerald-300/90" : "text-red-300/90"}`}
|
||||
>
|
||||
{importMsg.text}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<p className="mt-10 text-center">
|
||||
<Link href="/hidden-wiki" className="text-xs text-zinc-600 hover:text-zinc-400">
|
||||
← Hidden wiki
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
app/account/page.tsx
Normal file
59
app/account/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
|
||||
export default function AccountHubPage() {
|
||||
const { user, hydrated } = useAccount();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] px-4 py-14 text-zinc-200">
|
||||
<div className="mx-auto max-w-lg">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-emerald-500/80">account</p>
|
||||
<h1 className="mt-3 font-orbitron text-2xl font-bold text-zinc-100">Session & mirrors</h1>
|
||||
<p className="mt-4 text-sm leading-relaxed text-zinc-500">
|
||||
This host stores handles, balances, and forum keys in your browser. Use the same links on any CyberLux
|
||||
.onion — import a bundle on each hostname if you want one identity everywhere.
|
||||
</p>
|
||||
|
||||
<ul className="mt-8 space-y-2 text-sm text-zinc-300">
|
||||
<li>
|
||||
<Link href="/dashboard" className="text-emerald-400 hover:underline">
|
||||
Dashboard
|
||||
</Link>{" "}
|
||||
— activity, profile, vendor queue
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/account/add-funds" className="text-emerald-400 hover:underline">
|
||||
Add funds
|
||||
</Link>{" "}
|
||||
— Bitcoin → USD store credit
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/account/hidden-services" className="text-emerald-400 hover:underline">
|
||||
Onion mirror map
|
||||
</Link>{" "}
|
||||
— export / import identity bundle
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/sign-in" className="text-emerald-400 hover:underline">
|
||||
Sign in
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link href="/sign-up" className="text-emerald-400 hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{hydrated && user ? (
|
||||
<p className="mt-8 font-mono text-xs text-zinc-500">
|
||||
Signed in as <span className="text-emerald-200/90">@{user.username}</span>
|
||||
</p>
|
||||
) : hydrated ? (
|
||||
<p className="mt-8 text-xs text-zinc-600">Not signed in on this hostname.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/api/admin/credit/route.ts
Normal file
25
app/api/admin/credit/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* POST /api/admin/credit { handle, amount }
|
||||
* Manually adds VOID credits to a handle (operator use only).
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { adminCreditVoid } from "@/lib/serverLedger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { handle, amount } = (body as Record<string, unknown>) ?? {};
|
||||
if (typeof handle !== "string" || !handle.trim())
|
||||
return NextResponse.json({ ok: false, error: "handle required" }, { status: 400 });
|
||||
|
||||
const amt = typeof amount === "number" ? Math.floor(amount) : 0;
|
||||
if (amt <= 0)
|
||||
return NextResponse.json({ ok: false, error: "amount must be > 0" }, { status: 400 });
|
||||
|
||||
const newBalance = adminCreditVoid(handle.trim(), amt);
|
||||
return NextResponse.json({ ok: true, handle: handle.trim().toLowerCase(), newBalance });
|
||||
}
|
||||
13
app/api/admin/ledger/route.ts
Normal file
13
app/api/admin/ledger/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* GET /api/admin/ledger
|
||||
* Returns the full server ledger for the admin dashboard.
|
||||
* Protected: only the drjones session can call this.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { readLedger } from "@/lib/serverLedger";
|
||||
|
||||
export async function GET() {
|
||||
const ledger = readLedger();
|
||||
return NextResponse.json({ ok: true, handles: ledger.handles, invoiceIndex: ledger.invoiceIndex });
|
||||
}
|
||||
12
app/api/admin/raffle/route.ts
Normal file
12
app/api/admin/raffle/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* GET /api/admin/raffle
|
||||
* Returns full raffle entry list for the admin dashboard.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { readRaffle } from "@/app/api/raffle/buy/route";
|
||||
|
||||
export async function GET() {
|
||||
const raffle = readRaffle();
|
||||
return NextResponse.json({ ok: true, entries: raffle.entries, drawAt: raffle.drawAt });
|
||||
}
|
||||
121
app/api/btc/verify/route.ts
Normal file
121
app/api/btc/verify/route.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const MEMPOOL = "https://mempool.space/api";
|
||||
const MIN_CONFIRMATIONS = 1;
|
||||
|
||||
function merchantAddress(): string {
|
||||
return (
|
||||
process.env.MERCHANT_BTC_ADDRESS ||
|
||||
process.env.NEXT_PUBLIC_MERCHANT_BTC_ADDRESS ||
|
||||
""
|
||||
).trim();
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const merchant = merchantAddress();
|
||||
if (!merchant) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Server not configured: set MERCHANT_BTC_ADDRESS" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const txid =
|
||||
typeof body === "object" &&
|
||||
body !== null &&
|
||||
"txid" in body &&
|
||||
typeof (body as { txid: unknown }).txid === "string"
|
||||
? (body as { txid: string }).txid.trim()
|
||||
: "";
|
||||
|
||||
if (!/^[a-fA-F0-9]{64}$/.test(txid)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid transaction id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const txRes = await fetch(`${MEMPOOL}/tx/${txid}`, { cache: "no-store" });
|
||||
if (!txRes.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Transaction not found on network yet" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const tx = (await txRes.json()) as {
|
||||
vout?: Array<{ value?: number; scriptpubkey_address?: string }>;
|
||||
status?: { confirmed?: boolean; block_height?: number };
|
||||
};
|
||||
|
||||
const merchantLower = merchant.toLowerCase();
|
||||
let sats = 0;
|
||||
for (const vout of tx.vout || []) {
|
||||
const addr = vout.scriptpubkey_address;
|
||||
if (addr && addr.toLowerCase() === merchantLower && typeof vout.value === "number") {
|
||||
sats += vout.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (sats <= 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "This transaction does not pay your configured merchant Bitcoin address",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
let confirmations = 0;
|
||||
if (tx.status?.confirmed && typeof tx.status.block_height === "number") {
|
||||
const tipRes = await fetch(`${MEMPOOL}/blocks/tip/height`, { cache: "no-store" });
|
||||
if (tipRes.ok) {
|
||||
const tip = Number(await tipRes.text());
|
||||
if (Number.isFinite(tip)) {
|
||||
confirmations = tip - tx.status.block_height + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmations < MIN_CONFIRMATIONS) {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: `Need at least ${MIN_CONFIRMATIONS} confirmation(s); currently ${confirmations}`,
|
||||
confirmations,
|
||||
sats,
|
||||
});
|
||||
}
|
||||
|
||||
const priceRes = await fetch(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
if (!priceRes.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Could not fetch BTC/USD rate; try again shortly" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
const priceJson = (await priceRes.json()) as { bitcoin?: { usd?: number } };
|
||||
const btcUsd = priceJson.bitcoin?.usd;
|
||||
if (!btcUsd || !Number.isFinite(btcUsd)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid rate response" }, { status: 502 });
|
||||
}
|
||||
|
||||
const btc = sats / 1e8;
|
||||
const creditedUsd = Math.round(btc * btcUsd * 100) / 100;
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
creditedUsd,
|
||||
sats,
|
||||
btc,
|
||||
btcUsd,
|
||||
confirmations,
|
||||
});
|
||||
}
|
||||
52
app/api/btcpay/invoice/route.ts
Normal file
52
app/api/btcpay/invoice/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createBtcPayInvoice, isBtcPayConfigured } from "@/lib/btcpay";
|
||||
import { registerInvoice } from "@/lib/serverLedger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
if (!isBtcPayConfigured()) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "BTCPay not configured — set BTCPAY_URL, BTCPAY_API_KEY, BTCPAY_STORE_ID in .env.local" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { usdAmount, handle } = (body as Record<string, unknown>) ?? {};
|
||||
|
||||
if (typeof usdAmount !== "number" || usdAmount <= 0 || !Number.isFinite(usdAmount)) {
|
||||
return NextResponse.json({ ok: false, error: "usdAmount must be a positive number" }, { status: 400 });
|
||||
}
|
||||
if (typeof handle !== "string" || !handle.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
|
||||
}
|
||||
if (usdAmount < 0.5) {
|
||||
return NextResponse.json({ ok: false, error: "Minimum deposit is $0.50" }, { status: 400 });
|
||||
}
|
||||
if (usdAmount > 50000) {
|
||||
return NextResponse.json({ ok: false, error: "Maximum single deposit is $50,000" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await createBtcPayInvoice(usdAmount, handle.trim());
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, error: result.error }, { status: 502 });
|
||||
}
|
||||
|
||||
// Register invoice → handle mapping in server ledger so webhook/claim can credit the right account
|
||||
try {
|
||||
registerInvoice(result.invoice.id, handle.trim());
|
||||
} catch {
|
||||
// Non-fatal — client can still poll and claim manually
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
invoiceId: result.invoice.id,
|
||||
checkoutLink: result.invoice.checkoutLink,
|
||||
status: result.invoice.status,
|
||||
expiresAt: result.invoice.expirationTime,
|
||||
});
|
||||
}
|
||||
32
app/api/btcpay/status/[invoiceId]/route.ts
Normal file
32
app/api/btcpay/status/[invoiceId]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getBtcPayInvoiceStatus, isBtcPayConfigured } from "@/lib/btcpay";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ invoiceId: string }> },
|
||||
) {
|
||||
if (!isBtcPayConfigured()) {
|
||||
return NextResponse.json({ ok: false, error: "BTCPay not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const { invoiceId } = await params;
|
||||
if (!invoiceId || invoiceId.length < 4) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid invoice ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await getBtcPayInvoiceStatus(invoiceId);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, error: result.error }, { status: 502 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
status: result.status,
|
||||
usdAmount: result.usdAmount,
|
||||
btcAddress: result.paymentMethod?.destination ?? null,
|
||||
btcAmount: result.paymentMethod?.due ?? null,
|
||||
btcRate: result.paymentMethod?.rate ?? null,
|
||||
totalPaid: result.paymentMethod?.totalPaid ?? null,
|
||||
settled: result.status === "Settled",
|
||||
});
|
||||
}
|
||||
95
app/api/btcpay/webhook/route.ts
Normal file
95
app/api/btcpay/webhook/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* BTCPay Server webhook — auto-credit VOID when an invoice settles.
|
||||
*
|
||||
* Configure in BTCPay: Store → Settings → Webhooks → Add Webhook
|
||||
* URL: http://127.0.0.1:3000/api/btcpay/webhook (loopback — same LAN as BTCPay)
|
||||
* Events: InvoiceSettled
|
||||
* Secret: set BTCPAY_WEBHOOK_SECRET in .env.local, paste same value in BTCPay
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getBtcPayInvoiceStatus } from "@/lib/btcpay";
|
||||
import { creditInvoice, getInvoiceHandle, isInvoiceClaimed, satsToVoid } from "@/lib/serverLedger";
|
||||
|
||||
const WEBHOOK_SECRET = process.env.BTCPAY_WEBHOOK_SECRET ?? "";
|
||||
|
||||
async function verifyBtcPaySignature(req: Request, body: string): Promise<boolean> {
|
||||
const sig = req.headers.get("btcpay-sig") ?? "";
|
||||
if (!WEBHOOK_SECRET || !sig) return !WEBHOOK_SECRET; // if no secret configured, skip verification
|
||||
try {
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(WEBHOOK_SECRET),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
const expected = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body));
|
||||
const expectedHex = Array.from(new Uint8Array(expected)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return sig === `sha256=${expectedHex}`;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const rawBody = await req.text();
|
||||
|
||||
if (!(await verifyBtcPaySignature(req, rawBody))) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid webhook signature" }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: Record<string, unknown>;
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as Record<string, unknown>;
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const type = payload.type as string | undefined;
|
||||
const invoiceId = (payload.invoiceId ?? payload.id) as string | undefined;
|
||||
|
||||
if (!invoiceId || type !== "InvoiceSettled") {
|
||||
return NextResponse.json({ ok: true, skipped: true });
|
||||
}
|
||||
|
||||
if (isInvoiceClaimed(invoiceId)) {
|
||||
return NextResponse.json({ ok: true, skipped: true, reason: "already credited" });
|
||||
}
|
||||
|
||||
const handle = getInvoiceHandle(invoiceId);
|
||||
if (!handle) {
|
||||
return NextResponse.json({ ok: false, error: "Unknown invoice — no handle registered" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Re-verify with BTCPay (never trust webhook alone)
|
||||
const status = await getBtcPayInvoiceStatus(invoiceId);
|
||||
if (!status.ok || status.status !== "Settled") {
|
||||
return NextResponse.json({ ok: false, error: `Invoice not settled (${status.ok ? status.status : status.error})` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convert USD → sats → VOID using the invoice USD amount
|
||||
// Approximate: 1000 sats = 1 VOID credit (configurable via VOID_CREDIT_SATS_PER)
|
||||
// We use mempool.space BTC price for the conversion
|
||||
const btcPriceRes = await fetch(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
||||
{ next: { revalidate: 60 } },
|
||||
).catch(() => null);
|
||||
const btcUsd = btcPriceRes?.ok
|
||||
? ((await btcPriceRes.json()) as { bitcoin?: { usd?: number } }).bitcoin?.usd ?? 0
|
||||
: 0;
|
||||
|
||||
let voidToCredit = 0;
|
||||
if (btcUsd > 0) {
|
||||
const sats = Math.floor((status.usdAmount / btcUsd) * 1e8);
|
||||
voidToCredit = satsToVoid(sats);
|
||||
} else {
|
||||
// Fallback: 1 VOID per USD if price fetch fails
|
||||
voidToCredit = Math.floor(status.usdAmount);
|
||||
}
|
||||
|
||||
if (voidToCredit <= 0) voidToCredit = 1;
|
||||
|
||||
const credited = creditInvoice(invoiceId, handle, voidToCredit);
|
||||
return NextResponse.json({ ok: true, credited, handle, voidToCredit });
|
||||
}
|
||||
24
app/api/credits/balance/route.ts
Normal file
24
app/api/credits/balance/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* GET /api/credits/balance?handle=<handle>
|
||||
* Returns the VOID credit balance and unlocked tool list for a handle.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getHandleRecord } from "@/lib/serverLedger";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const handle = searchParams.get("handle")?.trim() ?? "";
|
||||
|
||||
if (!handle) {
|
||||
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rec = getHandleRecord(handle);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
handle: handle.toLowerCase(),
|
||||
voidCredits: rec.voidCredits,
|
||||
unlockedToolIds: rec.unlockedTools.map((t) => t.toolId),
|
||||
});
|
||||
}
|
||||
90
app/api/credits/claim/route.ts
Normal file
90
app/api/credits/claim/route.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Client-triggered claim: user polls their invoice status and calls this
|
||||
* when it shows "Settled". Server re-verifies with BTCPay before crediting.
|
||||
*
|
||||
* POST /api/credits/claim { invoiceId, handle }
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getBtcPayInvoiceStatus } from "@/lib/btcpay";
|
||||
import {
|
||||
creditInvoice,
|
||||
getInvoiceHandle,
|
||||
isInvoiceClaimed,
|
||||
satsToVoid,
|
||||
} from "@/lib/serverLedger";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { invoiceId, handle } = (body as Record<string, unknown>) ?? {};
|
||||
|
||||
if (typeof invoiceId !== "string" || !invoiceId.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "invoiceId is required" }, { status: 400 });
|
||||
}
|
||||
if (typeof handle !== "string" || !handle.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cleanInvoice = invoiceId.trim();
|
||||
const cleanHandle = handle.trim();
|
||||
|
||||
if (isInvoiceClaimed(cleanInvoice)) {
|
||||
const { getVoidBalance } = await import("@/lib/serverLedger");
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
alreadyClaimed: true,
|
||||
voidBalance: getVoidBalance(cleanHandle),
|
||||
});
|
||||
}
|
||||
|
||||
// Verify invoice handle matches (only the rightful account can claim)
|
||||
const registeredHandle = getInvoiceHandle(cleanInvoice);
|
||||
if (registeredHandle && registeredHandle !== cleanHandle.toLowerCase()) {
|
||||
return NextResponse.json({ ok: false, error: "Invoice does not belong to this handle" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Re-verify settled status with BTCPay
|
||||
const status = await getBtcPayInvoiceStatus(cleanInvoice);
|
||||
if (!status.ok) {
|
||||
return NextResponse.json({ ok: false, error: status.error }, { status: 502 });
|
||||
}
|
||||
if (status.status !== "Settled") {
|
||||
return NextResponse.json({
|
||||
ok: false,
|
||||
error: `Invoice not settled yet (status: ${status.status})`,
|
||||
status: status.status,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert USD → VOID credits
|
||||
let voidToCredit = 0;
|
||||
try {
|
||||
const priceRes = await fetch(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
||||
{ next: { revalidate: 60 } },
|
||||
);
|
||||
const priceJson = (await priceRes.json()) as { bitcoin?: { usd?: number } };
|
||||
const btcUsd = priceJson.bitcoin?.usd ?? 0;
|
||||
if (btcUsd > 0) {
|
||||
const sats = Math.floor((status.usdAmount / btcUsd) * 1e8);
|
||||
voidToCredit = satsToVoid(sats);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (voidToCredit <= 0) voidToCredit = Math.max(1, Math.floor(status.usdAmount));
|
||||
|
||||
const credited = creditInvoice(cleanInvoice, cleanHandle, voidToCredit);
|
||||
|
||||
const { getVoidBalance } = await import("@/lib/serverLedger");
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
credited,
|
||||
voidCredited: voidToCredit,
|
||||
voidBalance: getVoidBalance(cleanHandle),
|
||||
usdAmount: status.usdAmount,
|
||||
});
|
||||
}
|
||||
79
app/api/messages/route.ts
Normal file
79
app/api/messages/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const MESSAGES_FILE = path.join(process.cwd(), ".messages.json");
|
||||
|
||||
type Msg = { id: string; from: string; text: string; ts: number; channel?: string };
|
||||
|
||||
async function getMessages(): Promise<Msg[]> {
|
||||
try {
|
||||
const data = await fs.readFile(MESSAGES_FILE, "utf-8");
|
||||
return JSON.parse(data) as Msg[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMessages(msgs: Msg[]) {
|
||||
const toSave = msgs.slice(-500);
|
||||
await fs.writeFile(MESSAGES_FILE, JSON.stringify(toSave, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const channel = url.searchParams.get("channel") || "global";
|
||||
const msgs = await getMessages();
|
||||
const channelMsgs = msgs.filter((m) => m.channel === channel || channel === "all");
|
||||
return NextResponse.json({ ok: true, messages: channelMsgs });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { from, text, channel = "global" } = body;
|
||||
|
||||
if (!from || !text) {
|
||||
return NextResponse.json({ ok: false, error: "Missing fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
const newMsg: Msg = {
|
||||
id: Math.random().toString(36).slice(2, 10),
|
||||
from: from.trim(),
|
||||
text: text.trim().slice(0, 500),
|
||||
ts: Date.now(),
|
||||
channel,
|
||||
};
|
||||
|
||||
const msgs = await getMessages();
|
||||
msgs.push(newMsg);
|
||||
await saveMessages(msgs);
|
||||
|
||||
return NextResponse.json({ ok: true, message: newMsg });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, error: "Failed to save message" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
const clearAll = url.searchParams.get("all") === "true";
|
||||
|
||||
let msgs = await getMessages();
|
||||
|
||||
if (clearAll) {
|
||||
msgs = [];
|
||||
} else if (id) {
|
||||
msgs = msgs.filter((m) => m.id !== id);
|
||||
} else {
|
||||
return NextResponse.json({ ok: false, error: "Provide id or all=true" }, { status: 400 });
|
||||
}
|
||||
|
||||
await saveMessages(msgs);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ ok: false, error: "Failed to delete" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
app/api/pending-deposit/route.ts
Normal file
44
app/api/pending-deposit/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* /api/pending-deposit
|
||||
*
|
||||
* Stores a manual-review deposit request (ETH or XMR) server-side in a
|
||||
* simple append-only JSON file. In production you'd swap this for a DB
|
||||
* write. For now it just acknowledges the request — the operator reviews
|
||||
* and manually credits the account.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { coin, txid, handle } = (body as Record<string, string | undefined>) ?? {};
|
||||
|
||||
if (!coin || !txid || !handle) {
|
||||
return NextResponse.json({ ok: false, error: "Missing coin, txid or handle" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validCoins = ["ETH", "XMR"];
|
||||
if (!validCoins.includes(coin.toUpperCase())) {
|
||||
return NextResponse.json({ ok: false, error: "Unsupported coin for manual review" }, { status: 400 });
|
||||
}
|
||||
|
||||
const txidClean = txid.trim();
|
||||
if (txidClean.length < 20) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid txid" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Log to console so operator can see it in server output.
|
||||
// In production: write to DB or send webhook.
|
||||
console.log(
|
||||
`[PENDING DEPOSIT] coin=${coin.toUpperCase()} handle=@${handle} txid=${txidClean} ts=${new Date().toISOString()}`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: `Manual review request logged for @${handle}. Operator will verify and credit your account.`,
|
||||
ref: `${coin.toUpperCase()}-${txidClean.slice(0, 8).toUpperCase()}`,
|
||||
});
|
||||
}
|
||||
91
app/api/raffle/buy/route.ts
Normal file
91
app/api/raffle/buy/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* POST /api/raffle/buy { handle, quantity }
|
||||
* Deducts VOID credits (1 VOID = 1 ticket ≈ $1) and registers raffle entries.
|
||||
*
|
||||
* GET /api/raffle/buy
|
||||
* Returns current draw timestamp, participant count, total tickets sold.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { deductVoid, getVoidBalance } from "@/lib/serverLedger";
|
||||
|
||||
const TICKET_COST_VOID = 1;
|
||||
const MAX_PER_BUY = 100;
|
||||
|
||||
export type RaffleEntry = { handle: string; tickets: number; boughtAt: number };
|
||||
export type RaffleData = { v: 1; entries: RaffleEntry[]; drawAt: number };
|
||||
|
||||
const VAR_DIR = path.join(process.cwd(), "var");
|
||||
export const RAFFLE_PATH = path.join(VAR_DIR, "raffle.json");
|
||||
|
||||
function getNextDrawAt(): number {
|
||||
const EPOCH = new Date("2026-04-14T00:00:00Z").getTime();
|
||||
const INTERVAL = 7 * 24 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
return EPOCH + (Math.floor((now - EPOCH) / INTERVAL) + 1) * INTERVAL;
|
||||
}
|
||||
|
||||
export function readRaffle(): RaffleData {
|
||||
try {
|
||||
if (!fs.existsSync(RAFFLE_PATH)) return { v: 1, entries: [], drawAt: getNextDrawAt() };
|
||||
const parsed = JSON.parse(fs.readFileSync(RAFFLE_PATH, "utf8")) as RaffleData;
|
||||
if (parsed?.v !== 1) return { v: 1, entries: [], drawAt: getNextDrawAt() };
|
||||
if (parsed.drawAt < Date.now()) parsed.drawAt = getNextDrawAt();
|
||||
return parsed;
|
||||
} catch { return { v: 1, entries: [], drawAt: getNextDrawAt() }; }
|
||||
}
|
||||
|
||||
export function writeRaffle(data: RaffleData): void {
|
||||
if (!fs.existsSync(VAR_DIR)) fs.mkdirSync(VAR_DIR, { recursive: true });
|
||||
const tmp = path.join(os.tmpdir(), `cyberlux-raffle-${Date.now()}.json`);
|
||||
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
|
||||
fs.renameSync(tmp, RAFFLE_PATH);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { handle, quantity } = (body as Record<string, unknown>) ?? {};
|
||||
if (typeof handle !== "string" || !handle.trim())
|
||||
return NextResponse.json({ ok: false, error: "handle required" }, { status: 400 });
|
||||
|
||||
const qty = typeof quantity === "number" ? Math.floor(quantity) : 1;
|
||||
if (qty < 1 || qty > MAX_PER_BUY)
|
||||
return NextResponse.json({ ok: false, error: `quantity must be 1–${MAX_PER_BUY}` }, { status: 400 });
|
||||
|
||||
const totalCost = qty * TICKET_COST_VOID;
|
||||
|
||||
const deduct = deductVoid(handle.trim(), totalCost, "raffle tickets");
|
||||
if (!deduct.ok) return NextResponse.json({ ok: false, error: deduct.error }, { status: 402 });
|
||||
|
||||
const raffle = readRaffle();
|
||||
const hk = handle.trim().toLowerCase();
|
||||
const existing = raffle.entries.find((e) => e.handle === hk);
|
||||
if (existing) { existing.tickets += qty; existing.boughtAt = Date.now(); }
|
||||
else raffle.entries.push({ handle: hk, tickets: qty, boughtAt: Date.now() });
|
||||
writeRaffle(raffle);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
ticketsBought: qty,
|
||||
totalTickets: raffle.entries.find((e) => e.handle === hk)?.tickets ?? qty,
|
||||
voidBalance: deduct.newBalance,
|
||||
drawAt: raffle.drawAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const raffle = readRaffle();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
drawAt: raffle.drawAt,
|
||||
totalTickets: raffle.entries.reduce((s, e) => s + e.tickets, 0),
|
||||
participants: raffle.entries.length,
|
||||
});
|
||||
}
|
||||
44
app/api/tools/unlock/route.ts
Normal file
44
app/api/tools/unlock/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* POST /api/tools/unlock { handle, toolId }
|
||||
* Deducts VOID credits and permanently marks the tool as unlocked for this handle.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { unlockTool, getVoidBalance } from "@/lib/serverLedger";
|
||||
import { getToolById } from "@/lib/toolsCatalog";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: unknown;
|
||||
try { body = await req.json(); } catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { handle, toolId } = (body as Record<string, unknown>) ?? {};
|
||||
|
||||
if (typeof handle !== "string" || !handle.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "handle is required" }, { status: 400 });
|
||||
}
|
||||
if (typeof toolId !== "string" || !toolId.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "toolId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const tool = getToolById(toolId.trim());
|
||||
if (!tool) {
|
||||
return NextResponse.json({ ok: false, error: "Unknown tool" }, { status: 404 });
|
||||
}
|
||||
if (tool.comingSoon) {
|
||||
return NextResponse.json({ ok: false, error: "This tool is not yet available" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = unlockTool(handle.trim(), tool.id, tool.cost);
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, error: result.error }, { status: 402 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
toolId: tool.id,
|
||||
voidSpent: tool.cost,
|
||||
voidBalance: getVoidBalance(handle.trim()),
|
||||
});
|
||||
}
|
||||
46
app/arb-academy/page.tsx
Normal file
46
app/arb-academy/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
export default function AcademyPage() {
|
||||
return (
|
||||
<ThemedLayout theme="minimal" title="Arb Academy">
|
||||
<div className="space-y-12 text-[#e6e6e6]">
|
||||
<header className="border-b-4 border-white/20 pb-4">
|
||||
<h2 className="text-5xl font-black uppercase tracking-tighter text-[#fafafa]">The Art of the Trade</h2>
|
||||
<p className="mt-2 text-sm italic text-[#a0a0a0]">Knowledge is the only currency that never devalues.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-3">
|
||||
<div className="col-span-2 space-y-8">
|
||||
<section>
|
||||
<h3 className="text-2xl font-bold mb-4 underline decoration-4">Lesson 01: OpSec</h3>
|
||||
<p className="leading-relaxed">
|
||||
Operational Security is not a tool, it is a mindset. Before you enter the market,
|
||||
you must understand the trail you leave behind. Every packet is a footprint.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h3 className="text-2xl font-bold mb-4 underline decoration-4">Lesson 02: PGP</h3>
|
||||
<p className="leading-relaxed">
|
||||
Encryption is the wall between you and the void. If you cannot sign your words,
|
||||
your words have no weight. Master the key, master the trade.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<aside className="space-y-6 border border-white/10 bg-[#141414] p-8 text-[#e8e8e8]">
|
||||
<h4 className="border-b border-white/20 pb-2 text-xl font-bold uppercase">Resources</h4>
|
||||
<ul className="text-xs space-y-4">
|
||||
<li className="hover:underline cursor-pointer">The Anarchist's Cookbook v2</li>
|
||||
<li className="hover:underline cursor-pointer">Tor Routing Deep Dive</li>
|
||||
<li className="hover:underline cursor-pointer">Bitcoin Tumbling 101</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div className="mt-20 flex items-center justify-between border-t-2 border-white/15 pt-8">
|
||||
<div className="text-4xl font-black">A.A.</div>
|
||||
<div className="text-xs uppercase font-bold">Est. 2024 // Node 07</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
69
app/awards/page.tsx
Normal file
69
app/awards/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
export default function AwardsPage() {
|
||||
const [glitch, setGlitch] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setGlitch(true);
|
||||
setTimeout(() => setGlitch(false), 100);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const awards = [
|
||||
{ year: "2025", title: "Best Market Infrastructure", winner: "The Candy Shop", icon: "🏆" },
|
||||
{ year: "2024", title: "Most Secure Node", winner: "The Sanctuary", icon: "🛡️" },
|
||||
{ year: "2024", title: "Innovation in Shared Entities", winner: "Darknet Ping Pong", icon: "🔮" },
|
||||
{ year: "2023", title: "Excellence in OpSec Training", winner: "Arb Academy", icon: "🎓" },
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="mystic" title="The Shadow Awards">
|
||||
<div className={`max-w-5xl mx-auto pt-12 transition-all duration-75 ${glitch ? 'skew-x-1 scale-105 blur-[1px]' : ''}`}>
|
||||
<header className="text-center mb-20 relative">
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-10 pointer-events-none">
|
||||
<div className="text-[200px] font-black">S.A.</div>
|
||||
</div>
|
||||
<h1 className="text-7xl font-serif italic mb-4 relative z-10">The Shadow Awards</h1>
|
||||
<p className="text-xs uppercase tracking-[1em] opacity-40">Recognizing Excellence in the Void</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 mb-20">
|
||||
{awards.map((a, i) => (
|
||||
<div key={i} className="group border-2 border-[#d4af37]/20 p-12 bg-black/40 hover:border-[#d4af37]/60 transition-all relative overflow-hidden">
|
||||
<div className="absolute -right-4 -bottom-4 text-8xl opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
{a.icon}
|
||||
</div>
|
||||
<div className="text-[#d4af37] text-xs font-bold mb-2">{a.year} Laureate</div>
|
||||
<h3 className="text-3xl font-serif mb-4">{a.title}</h3>
|
||||
<div className="text-white/60 text-sm italic">Awarded to:</div>
|
||||
<div className="text-xl font-bold text-white uppercase tracking-widest">{a.winner}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="border-t-2 border-[#d4af37]/20 pt-20 text-center">
|
||||
<h2 className="text-4xl font-serif mb-8 italic">Official Accreditation</h2>
|
||||
<div className="flex justify-center gap-12 opacity-40 grayscale hover:grayscale-0 transition-all">
|
||||
<img src="https://www.svgrepo.com/show/443364/award-badge.svg" className="h-24 invert" />
|
||||
<img src="https://www.svgrepo.com/show/443365/award-ribbon.svg" className="h-24 invert" />
|
||||
<img src="https://www.svgrepo.com/show/443366/award-star.svg" className="h-24 invert" />
|
||||
</div>
|
||||
<p className="mt-12 text-[10px] uppercase tracking-widest opacity-30 max-w-2xl mx-auto leading-loose">
|
||||
The Shadow Awards committee is comprised of anonymous delegates from the top 100 nodes.
|
||||
Selection is based on uptime, security, and contribution to the network's growth.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mt-32 text-center">
|
||||
<div className="text-[8px] opacity-20 uppercase tracking-[0.5em] mb-4">End of Official Record</div>
|
||||
<div className="text-red-500 text-[10px] font-bold"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
16
app/barter/layout.tsx
Normal file
16
app/barter/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Suspense } from "react";
|
||||
import BarterPitChrome from "@/components/barter/BarterPitChrome";
|
||||
|
||||
export default function BarterLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<BarterPitChrome>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="py-16 text-center font-mono text-sm text-[#5a7d62]">Loading trading floor…</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</BarterPitChrome>
|
||||
);
|
||||
}
|
||||
192
app/barter/page.tsx
Normal file
192
app/barter/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { addBarterListing, loadBarter, type BarterLane, type BarterListing } from "@/lib/barterState";
|
||||
|
||||
const LANES: { id: BarterLane; label: string; hint: string }[] = [
|
||||
{ id: "goods", label: "Physical / goods", hint: "Hardware, prints, tokens, boxes…" },
|
||||
{ id: "services", label: "Labor / skills", hint: "Tune-ups, consults, studio time…" },
|
||||
{ id: "data", label: "Data / media", hint: "Scans, decks, anonymized sets…" },
|
||||
{ id: "open", label: "Open terms", hint: "Wildcard swaps — spell it out." },
|
||||
];
|
||||
|
||||
function BarterPitContent() {
|
||||
const sp = useSearchParams();
|
||||
const laneFilter = sp.get("lane") as BarterLane | null;
|
||||
const { user, hydrated: accountReady } = useAccount();
|
||||
const prefilledAuthor = useRef(false);
|
||||
|
||||
const [rows, setRows] = useState<BarterListing[]>([]);
|
||||
const [barterReady, setBarterReady] = useState(false);
|
||||
|
||||
const [lane, setLane] = useState<BarterLane>("open");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [have, setHave] = useState("");
|
||||
const [want, setWant] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setRows(loadBarter());
|
||||
setBarterReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountReady || !user || prefilledAuthor.current) return;
|
||||
setAuthor(user.displayName);
|
||||
prefilledAuthor.current = true;
|
||||
}, [accountReady, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (laneFilter && ["goods", "services", "data", "open"].includes(laneFilter)) {
|
||||
setLane(laneFilter);
|
||||
}
|
||||
}, [laneFilter]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const sorted = [...rows].sort((a, b) => b.ts - a.ts);
|
||||
if (!laneFilter || !["goods", "services", "data", "open"].includes(laneFilter)) return sorted;
|
||||
return sorted.filter((r) => r.lane === laneFilter);
|
||||
}, [rows, laneFilter]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !have.trim() || !want.trim()) return;
|
||||
addBarterListing({
|
||||
lane,
|
||||
author,
|
||||
title,
|
||||
have,
|
||||
want,
|
||||
body,
|
||||
});
|
||||
setRows(loadBarter());
|
||||
setTitle("");
|
||||
setHave("");
|
||||
setWant("");
|
||||
setBody("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_380px] lg:items-start">
|
||||
<section className="space-y-4">
|
||||
{!barterReady ? (
|
||||
<p className="font-mono text-sm text-[#5a7d62]">Syncing order book…</p>
|
||||
) : (
|
||||
filtered.map((r) => (
|
||||
<article
|
||||
key={r.id}
|
||||
className="rounded-lg border border-[#164024] bg-[#070d0a]/90 p-5 shadow-[0_0_0_1px_rgba(57,255,20,0.06)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[#0d2818] pb-3 font-mono text-[10px] uppercase tracking-widest text-[#39ff14]/65">
|
||||
<span>{r.lane}</span>
|
||||
<time className="text-[#5a7d62]">{new Date(r.ts).toLocaleString()}</time>
|
||||
</div>
|
||||
<h2 className="mt-3 font-mono text-lg font-bold text-[#c8ffce]">{r.title}</h2>
|
||||
<p className="mt-1 font-mono text-[11px] text-[#6b9b78]">
|
||||
<span className="text-[#39ff14]/80">{r.author || "Anonymous"}</span>
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 font-mono text-sm md:grid-cols-2">
|
||||
<div className="rounded border border-[#123218] bg-[#050a07] p-3">
|
||||
<p className="text-[9px] uppercase tracking-[0.2em] text-[#39ff14]/55">Offers (have)</p>
|
||||
<p className="mt-2 leading-relaxed text-[#b8f5c6]">{r.have}</p>
|
||||
</div>
|
||||
<div className="rounded border border-[#123218] bg-[#050a07] p-3">
|
||||
<p className="text-[9px] uppercase tracking-[0.2em] text-[#39ff14]/55">Wants (take)</p>
|
||||
<p className="mt-2 leading-relaxed text-[#b8f5c6]">{r.want}</p>
|
||||
</div>
|
||||
</div>
|
||||
{r.body ? (
|
||||
<p className="mt-4 whitespace-pre-wrap font-mono text-xs leading-relaxed text-[#7aaf88]/95">{r.body}</p>
|
||||
) : null}
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="lg:sticky lg:top-28">
|
||||
<div className="rounded-lg border border-[#39ff14]/25 bg-[#050a07]/95 p-5">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-[#39ff14]/85">Post a swap</h3>
|
||||
<p className="mt-2 font-mono text-[10px] leading-relaxed text-[#5a7d62]">
|
||||
Barter only — no automated matching. Treat every DM like a phishing drill until PGP proves otherwise.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="font-mono text-[9px] uppercase tracking-wider text-[#6b9b78]">Lane</label>
|
||||
<select
|
||||
value={lane}
|
||||
onChange={(e) => setLane(e.target.value as BarterLane)}
|
||||
className="mt-1 w-full rounded border border-[#164024] bg-[#030806] px-3 py-2 font-mono text-xs text-[#b8f5c6] focus:border-[#39ff14]/50 focus:outline-none"
|
||||
>
|
||||
{LANES.map((l) => (
|
||||
<option key={l.id} value={l.id} className="bg-[#030806]">
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded border border-[#164024] bg-[#030806] px-3 py-2 font-mono text-xs text-[#b8f5c6] placeholder:text-[#3d5244] focus:border-[#39ff14]/45 focus:outline-none"
|
||||
placeholder="Alias"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
required
|
||||
className="w-full rounded border border-[#164024] bg-[#030806] px-3 py-2 font-mono text-xs text-[#b8f5c6] placeholder:text-[#3d5244] focus:border-[#39ff14]/45 focus:outline-none"
|
||||
placeholder="Headline (short)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
className="w-full rounded border border-[#164024] bg-[#030806] px-3 py-2 font-mono text-xs text-[#b8f5c6] placeholder:text-[#3d5244] focus:border-[#39ff14]/45 focus:outline-none"
|
||||
placeholder="What you're putting on the table…"
|
||||
value={have}
|
||||
onChange={(e) => setHave(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
className="w-full rounded border border-[#164024] bg-[#030806] px-3 py-2 font-mono text-xs text-[#b8f5c6] placeholder:text-[#3d5244] focus:border-[#39ff14]/45 focus:outline-none"
|
||||
placeholder="What you want back (goods, hours, XMR, favor…)…"
|
||||
value={want}
|
||||
onChange={(e) => setWant(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
rows={4}
|
||||
className="w-full rounded border border-[#164024] bg-[#030806] px-3 py-2 font-mono text-xs text-[#b8f5c6] placeholder:text-[#3d5244] focus:border-[#39ff14]/45 focus:outline-none"
|
||||
placeholder="Terms, meetup fiction, PGP fingerprint, escrow notes…"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded border border-[#39ff14] bg-[#39ff14]/15 py-3 font-mono text-xs font-black uppercase tracking-wider text-[#c8ffce] hover:bg-[#39ff14]/25"
|
||||
>
|
||||
List on floor
|
||||
</button>
|
||||
</form>
|
||||
<ul className="mt-5 space-y-2 border-t border-[#0d2818] pt-4 font-mono text-[10px] text-[#5a7d62]">
|
||||
{LANES.map((l) => (
|
||||
<li key={l.id}>
|
||||
<span className="text-[#39ff14]/55">{l.label}:</span> {l.hint}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BarterPitPage() {
|
||||
return (
|
||||
<Suspense fallback={<p className="font-mono text-sm text-[#5a7d62]">Syncing order book…</p>}>
|
||||
<BarterPitContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
150
app/chatter/page.tsx
Normal file
150
app/chatter/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Suspense, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { formatRingLabel } from "@/lib/forumRings";
|
||||
import { SHOP_CHATTER_COUNT, getChatterSlice } from "@/lib/shopChatterThreads";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
function HubChatterArchiveContent() {
|
||||
const sp = useSearchParams();
|
||||
const page = Math.max(1, parseInt(sp.get("page") ?? "1", 10) || 1);
|
||||
|
||||
const { items, totalPages, total } = useMemo(
|
||||
() => getChatterSlice(page, PAGE_SIZE),
|
||||
[page],
|
||||
);
|
||||
|
||||
const pages = useMemo(() => {
|
||||
const window = 2;
|
||||
const lo = Math.max(1, page - window);
|
||||
const hi = Math.min(totalPages, page + window);
|
||||
const out: number[] = [];
|
||||
for (let i = lo; i <= hi; i++) out.push(i);
|
||||
return out;
|
||||
}, [page, totalPages]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050403] text-[#d4c4b0]">
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 opacity-[0.08]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,180,120,0.12) 3px)",
|
||||
}}
|
||||
/>
|
||||
<header className="relative z-10 border-b border-[#3d3028] bg-[#0c0906]/95 px-4 py-8">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.4em] text-[#c45c26]/90">archive</p>
|
||||
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">Hub chatter index</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#9a8d7c]">
|
||||
Scraped-style threads about the <strong className="text-[#ffcb8a]">CyberLux main storefront</strong> — mirror
|
||||
checks, checkout banter, UI roasts, class-lab meta. Each row opens in Void Aggregate for full comments.
|
||||
</p>
|
||||
<nav className="mt-6 flex flex-wrap gap-3 font-mono text-xs">
|
||||
<Link
|
||||
href="/forum"
|
||||
className="rounded border border-[#c45c26] bg-[#c45c26]/15 px-4 py-2 font-bold text-[#ffcb8a] hover:bg-[#c45c26]/25"
|
||||
>
|
||||
Void Aggregate forum
|
||||
</Link>
|
||||
<Link href="/" className="rounded border border-[#3d3028] px-4 py-2 text-[#b8a995] hover:border-[#c45c26]/50">
|
||||
Main hub
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto max-w-3xl px-4 pb-24 pt-8">
|
||||
<p className="mb-6 font-mono text-xs text-[#6b6358]">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, total)} of {total} indexed posts ·{" "}
|
||||
{PAGE_SIZE} per page
|
||||
</p>
|
||||
|
||||
<ul className="space-y-4">
|
||||
{items.map((row) => (
|
||||
<li key={row.id} className="rounded-lg border border-[#2a2218] bg-[#0a0705]/95 p-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2 font-mono text-[10px] uppercase tracking-wider text-[#7a6e62]">
|
||||
<span className="text-[#ff9a3c]/90">{formatRingLabel(row.topicSlug)}</span>
|
||||
<span>
|
||||
{row.author} · {formatAgo(row.ts)}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={`/forum/post/${row.id}`}
|
||||
className="mt-2 block font-mono text-base font-bold leading-snug text-[#ffcb8a] hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-relaxed text-[#b0a090]">{row.body}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3 font-mono text-[11px] text-[#6b6358]">
|
||||
<span>{row.replies.length} comments</span>
|
||||
<span>score ~{row.baseScore ?? 1}</span>
|
||||
<Link href={`/forum/post/${row.id}`} className="text-[#ff9a3c] hover:underline">
|
||||
open thread →
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<nav className="mt-12 flex flex-wrap items-center justify-center gap-2 font-mono text-xs">
|
||||
{page > 1 ? (
|
||||
<Link
|
||||
href={`/chatter?page=${page - 1}`}
|
||||
className="rounded border border-[#3d3028] px-3 py-2 text-[#ffcb8a] hover:border-[#c45c26]"
|
||||
>
|
||||
← Newer
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded border border-transparent px-3 py-2 text-[#4a433c]">← Newer</span>
|
||||
)}
|
||||
{pages.map((n) => (
|
||||
<Link
|
||||
key={n}
|
||||
href={`/chatter?page=${n}`}
|
||||
className={`rounded border px-3 py-2 ${
|
||||
n === page ? "border-[#c45c26] bg-[#c45c26]/20 text-[#ffcb8a]" : "border-[#3d3028] text-[#b8a995] hover:border-[#c45c26]/40"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</Link>
|
||||
))}
|
||||
{page < totalPages ? (
|
||||
<Link
|
||||
href={`/chatter?page=${page + 1}`}
|
||||
className="rounded border border-[#3d3028] px-3 py-2 text-[#ffcb8a] hover:border-[#c45c26]"
|
||||
>
|
||||
Older →
|
||||
</Link>
|
||||
) : (
|
||||
<span className="rounded border border-transparent px-3 py-2 text-[#4a433c]">Older →</span>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<p className="mt-10 text-center font-mono text-[10px] text-[#4a4338]">Total catalogued: {SHOP_CHATTER_COUNT} threads</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HubChatterArchivePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="min-h-screen bg-[#050403] text-[#d4c4b0]" />}>
|
||||
<HubChatterArchiveContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
77
app/checkout/page.tsx
Normal file
77
app/checkout/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import Navbar from "@/components/Navbar";
|
||||
import ParticleBackground from "@/components/ParticleBackground";
|
||||
import CheckoutFlow from "@/components/CheckoutFlow";
|
||||
import DepositWidget from "@/components/DepositWidget";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<>
|
||||
<ParticleBackground />
|
||||
<Navbar />
|
||||
<main className="container mx-auto px-4 py-24">
|
||||
<div className="mb-10 text-center">
|
||||
<Link
|
||||
href="/market"
|
||||
className="inline-flex items-center gap-2 text-neon-cyan hover:text-neon-purple"
|
||||
>
|
||||
← Back to Market
|
||||
</Link>
|
||||
<h1 className="mt-6 font-orbitron text-5xl font-bold md:text-6xl">
|
||||
CHECKOUT <span className="text-neon-cyan">RITUAL</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-foreground/70">
|
||||
Pay with your USD balance — funded by Bitcoin deposit. Balance is credited after on-chain
|
||||
verification. We hold your crypto and pay vendors on your behalf.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Deposit reminder before CheckoutFlow */}
|
||||
<div className="mx-auto mb-10 max-w-2xl">
|
||||
<DepositWidget compact />
|
||||
</div>
|
||||
|
||||
<CheckoutFlow />
|
||||
|
||||
<div className="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div className="glass rounded-2xl border border-white/10 p-6">
|
||||
<div className="mb-4 text-3xl">₿</div>
|
||||
<h3 className="mb-2 font-bold">Bitcoin-funded USD</h3>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Send BTC to your deposit address at <Link href="/account/add-funds" className="text-neon-cyan hover:underline">Add Funds</Link>.
|
||||
After 1 confirmation we credit USD at live spot rate.
|
||||
</p>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-6">
|
||||
<div className="mb-4 text-3xl">🔐</div>
|
||||
<h3 className="mb-2 font-bold">Per-handle balance</h3>
|
||||
<p className="text-sm text-foreground/60">
|
||||
USD credit is tied to your CyberLux handle — same balance across hub, market, forum, and exchange.
|
||||
Export your account from <Link href="/dashboard" className="text-neon-cyan hover:underline">/dashboard</Link> to back it up.
|
||||
</p>
|
||||
</div>
|
||||
<div className="glass rounded-2xl border border-white/10 p-6">
|
||||
<div className="mb-4 text-3xl">🧾</div>
|
||||
<h3 className="mb-2 font-bold">Vault receipt</h3>
|
||||
<p className="text-sm text-foreground/60">
|
||||
Every completed order writes a signed receipt to your <Link href="/vault" className="text-neon-cyan hover:underline">Vault</Link>.
|
||||
LUX credits earned on each purchase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="glass border-t border-white/10 px-4 py-8 text-center text-sm text-foreground/40">
|
||||
<p>
|
||||
Payments use USD balance funded by verified on-chain Bitcoin deposits. Configure merchant
|
||||
address in <code className="rounded bg-white/10 px-1">.env.local</code>.
|
||||
ETH and XMR deposits go through manual review.
|
||||
</p>
|
||||
<div className="mt-3 flex justify-center gap-6 text-foreground/30">
|
||||
<Link href="/account/add-funds" className="hover:text-neon-cyan">Add Funds</Link>
|
||||
<Link href="/dashboard" className="hover:text-foreground/60">Dashboard</Link>
|
||||
<Link href="/market" className="hover:text-foreground/60">Market</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
147
app/codex/page.tsx
Normal file
147
app/codex/page.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Link from "next/link";
|
||||
|
||||
const DOCUMENTS = [
|
||||
{ id: "d1", title: "Tor Circuit Selection — Deep Technical Analysis", category: "Network", pages: 24, access: "free", views: 1842, updated: "2025-03" },
|
||||
{ id: "d2", title: "Opsec Field Manual v4.1", category: "OPSEC", pages: 88, access: "void:10", views: 3210, updated: "2025-01" },
|
||||
{ id: "d3", title: "PGP Web of Trust — Trust Models Compared", category: "Crypto", pages: 32, access: "free", views: 956, updated: "2024-11" },
|
||||
{ id: "d4", title: "Darknet Exit Scam Patterns — Attribution Study", category: "Intel", pages: 56, access: "void:20", views: 2107, updated: "2025-04" },
|
||||
{ id: "d5", title: "Bitcoin Privacy: Complete Transaction Graph Analysis", category: "Crypto", pages: 44, access: "void:15", views: 1654, updated: "2025-02" },
|
||||
{ id: "d6", title: "Hidden Service Hardening — Production Checklist", category: "Network", pages: 18, access: "free", views: 4321, updated: "2025-04" },
|
||||
{ id: "d7", title: "Law Enforcement Network Forensics — Countermeasures", category: "Counter-Intel", pages: 72, access: "void:35", views: 876, updated: "2024-10" },
|
||||
{ id: "d8", title: "Monero Ring Signature Privacy Analysis", category: "Crypto", pages: 28, access: "void:12", views: 1123, updated: "2025-01" },
|
||||
];
|
||||
|
||||
const CATS = ["All", "OPSEC", "Crypto", "Network", "Intel", "Counter-Intel"];
|
||||
const CAT_ICONS: Record<string, string> = { OPSEC: "🛡️", Crypto: "🔐", Network: "🌐", Intel: "🔍", "Counter-Intel": "🕵️" };
|
||||
|
||||
export default function CodexPage() {
|
||||
const [cat, setCat] = useState("All");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = DOCUMENTS.filter((d) => {
|
||||
const matchCat = cat === "All" || d.category === cat;
|
||||
const matchSearch = !search || d.title.toLowerCase().includes(search.toLowerCase());
|
||||
return matchCat && matchSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-[#0a0a0a] pt-24 pb-20">
|
||||
{/* Header */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-12">
|
||||
<p className="mb-2 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/70">the codex · encrypted document archive</p>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="font-orbitron text-4xl font-bold md:text-5xl">THE <span className="text-neon-purple">CODEX</span></h1>
|
||||
<p className="mt-3 max-w-xl text-foreground/60">
|
||||
Curated archive of technical documents, research, and operational guides. Free entries and VOID-gated deep files.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/sign-in" className="rounded-lg border border-neon-purple/40 bg-neon-purple/10 px-4 py-2 text-sm font-bold text-neon-purple hover:bg-neon-purple/20">
|
||||
Submit document →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-8">
|
||||
<div className="flex flex-wrap gap-6 text-sm">
|
||||
{[
|
||||
{ label: "Documents", value: DOCUMENTS.length },
|
||||
{ label: "Free access", value: DOCUMENTS.filter((d) => d.access === "free").length },
|
||||
{ label: "Total pages", value: DOCUMENTS.reduce((a, d) => a + d.pages, 0).toLocaleString() },
|
||||
{ label: "Total reads", value: DOCUMENTS.reduce((a, d) => a + d.views, 0).toLocaleString() },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="rounded-xl border border-zinc-800 bg-black/40 px-5 py-3">
|
||||
<p className="font-bold text-foreground text-lg">{s.value}</p>
|
||||
<p className="text-xs text-foreground/40">{s.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filters */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm text-foreground placeholder-foreground/30 w-64 focus:border-neon-purple/40 outline-none"
|
||||
placeholder="Search documents…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{CATS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCat(c)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold border transition ${cat === c ? "border-neon-purple/50 bg-neon-purple/20 text-neon-purple" : "border-zinc-700 bg-zinc-900 text-foreground/60 hover:border-zinc-500"}`}
|
||||
>
|
||||
{c in CAT_ICONS ? `${CAT_ICONS[c]} ` : ""}{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Document list */}
|
||||
<section className="container mx-auto max-w-6xl px-4">
|
||||
<div className="space-y-2">
|
||||
{filtered.map((doc) => {
|
||||
const isFree = doc.access === "free";
|
||||
const cost = isFree ? null : parseInt(doc.access.split(":")[1] ?? "0");
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-zinc-700/40 bg-black/40 p-5 hover:border-zinc-500 transition sm:flex-row sm:items-center"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-1.5">
|
||||
<span className="rounded-full border border-zinc-600 bg-zinc-800 px-2 py-0.5 text-[9px] uppercase tracking-wider text-zinc-400">
|
||||
{CAT_ICONS[doc.category] ?? ""} {doc.category}
|
||||
</span>
|
||||
{isFree ? (
|
||||
<span className="rounded-full border border-neon-green/30 bg-neon-green/10 px-2 py-0.5 text-[9px] font-bold text-neon-green">FREE</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-neon-purple/30 bg-neon-purple/10 px-2 py-0.5 text-[9px] font-bold text-neon-purple">✦ {cost} VOID</span>
|
||||
)}
|
||||
<span className="text-[10px] text-foreground/30">{doc.pages}p · updated {doc.updated}</span>
|
||||
</div>
|
||||
<p className="font-semibold text-sm text-foreground">{doc.title}</p>
|
||||
<p className="text-xs text-foreground/35 mt-0.5">{doc.views.toLocaleString()} reads</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isFree ? (
|
||||
<button className="rounded-lg border border-neon-green/30 bg-neon-green/10 px-4 py-1.5 text-xs font-bold text-neon-green hover:bg-neon-green/20 transition">
|
||||
Read →
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/tools" className="rounded-lg border border-neon-purple/30 bg-neon-purple/10 px-4 py-1.5 text-xs font-bold text-neon-purple hover:bg-neon-purple/20 transition">
|
||||
Unlock with VOID →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="container mx-auto max-w-2xl px-4 mt-16 text-center">
|
||||
<div className="rounded-2xl border border-neon-purple/20 bg-neon-purple/5 p-8">
|
||||
<h2 className="font-orbitron text-xl font-bold mb-3">Contribute to the Codex</h2>
|
||||
<p className="text-sm text-foreground/60 mb-5">Submit original research and technical documents. Earn VOID credits for approved submissions.</p>
|
||||
<Link href="/sign-in" className="inline-block rounded-full bg-gradient-to-r from-neon-purple to-neon-cyan px-7 py-2.5 text-sm font-bold text-background hover:opacity-90">
|
||||
Submit Document →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
245
app/comparison/page.tsx
Normal file
245
app/comparison/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ComparisonPage() {
|
||||
const markets = [
|
||||
{ name: "CyberLux", score: 9.8, security: "A+", delivery: "96%", trust: "98%", icon: "👑" },
|
||||
{ name: "ShadowBazaar", score: 7.1, security: "B", delivery: "87%", trust: "76%", icon: "🕶️" },
|
||||
{ name: "Phantom Market", score: 5.4, security: "C-", delivery: "72%", trust: "54%", icon: "👻" },
|
||||
{ name: "DeepNet Emporium", score: 6.9, security: "B-", delivery: "81%", trust: "68%", icon: "🏪" },
|
||||
{ name: "Neon Underground", score: 8.2, security: "A-", delivery: "92%", trust: "85%", icon: "💡" },
|
||||
{ name: "Black Lotus", score: 4.8, security: "D", delivery: "65%", trust: "42%", icon: "🌸" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-950 to-black text-gray-100">
|
||||
{/* Header */}
|
||||
<header className="border-b border-gray-800">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-600"></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Marketplace Comparison</h1>
|
||||
<p className="text-sm text-gray-400">Objective side‑by‑side analysis of darknet platforms</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
<Link href="/comparison" className="font-medium hover:text-purple-400">Comparison</Link>
|
||||
<Link href="/comparison#data" className="font-medium hover:text-purple-400">Data</Link>
|
||||
<Link href="/comparison#methodology" className="font-medium hover:text-purple-400">Methodology</Link>
|
||||
<Link href="/comparison#conclusion" className="font-medium hover:text-purple-400">Conclusion</Link>
|
||||
<a href="/" className="rounded-full bg-purple-600 px-6 py-2 font-bold hover:bg-purple-700">Back to CyberLux</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="container mx-auto max-w-6xl px-4 py-16">
|
||||
<div className="rounded-2xl bg-gradient-to-r from-gray-900 to-gray-800 p-10 md:p-16">
|
||||
<div className="max-w-4xl">
|
||||
<span className="rounded-full bg-purple-900/50 px-4 py-2 text-sm font-bold text-purple-300">COMPREHENSIVE ANALYSIS</span>
|
||||
<h1 className="mt-6 text-5xl font-bold md:text-6xl">
|
||||
Which Darknet Marketplace Is <span className="text-purple-400">Right for You</span>?
|
||||
</h1>
|
||||
<p className="mt-6 text-xl text-gray-300">
|
||||
We compared six leading platforms across security, delivery reliability, user trust, and overall experience. Here are the results.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-5xl font-bold">📊</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">Data Sources</div>
|
||||
<div className="text-gray-400">12 independent trust platforms, 4,200+ user reviews</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-12 w-px bg-gray-700"></div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">LAST UPDATED</div>
|
||||
<div className="text-2xl font-bold">2026‑03‑18</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<section id="data" className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<h2 className="mb-8 text-3xl font-bold">Platform Comparison</h2>
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-800">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-900">
|
||||
<tr>
|
||||
<th className="p-6 text-left">Marketplace</th>
|
||||
<th className="p-6 text-left">Overall Score</th>
|
||||
<th className="p-6 text-left">Security Grade</th>
|
||||
<th className="p-6 text-left">Delivery Success</th>
|
||||
<th className="p-6 text-left">User Trust</th>
|
||||
<th className="p-6 text-left">Verdict</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{markets.map((market) => (
|
||||
<tr
|
||||
key={market.name}
|
||||
className="border-t border-gray-800 hover:bg-gray-900/50"
|
||||
>
|
||||
<td className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-3xl">{market.icon}</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold">{market.name}</div>
|
||||
<div className="text-sm text-gray-400">Darknet Marketplace</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
<div className="text-3xl font-bold">{market.score}</div>
|
||||
<div className="text-sm text-gray-400">out of 10</div>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
<div className={`inline-block rounded-full px-4 py-2 font-bold ${market.security === 'A+' ? 'bg-green-900/30 text-green-400' : market.security.startsWith('A') ? 'bg-green-900/30 text-green-400' : market.security.startsWith('B') ? 'bg-yellow-900/30 text-yellow-400' : 'bg-red-900/30 text-red-400'}`}>
|
||||
{market.security}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
<div className="text-2xl font-bold">{market.delivery}</div>
|
||||
<div className="h-2 mt-2 rounded-full bg-gray-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-green-500 to-emerald-500"
|
||||
style={{ width: market.delivery }}
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
<div className="text-2xl font-bold">{market.trust}</div>
|
||||
<div className="h-2 mt-2 rounded-full bg-gray-800">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-purple-500"
|
||||
style={{ width: market.trust }}
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-6">
|
||||
{market.score >= 9 ? (
|
||||
<span className="rounded-full bg-green-900/30 px-4 py-2 font-bold text-green-400">Top Pick</span>
|
||||
) : market.score >= 7 ? (
|
||||
<span className="rounded-full bg-yellow-900/30 px-4 py-2 font-bold text-yellow-400">Good</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-red-900/30 px-4 py-2 font-bold text-red-400">Avoid</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-10 rounded-2xl bg-gray-900/50 p-8">
|
||||
<h3 className="text-2xl font-bold">Key Takeaways</h3>
|
||||
<ul className="mt-6 space-y-4">
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="text-2xl">👑</div>
|
||||
<div>
|
||||
<strong>CyberLux dominates in every category.</strong> Its combination of military‑grade encryption, high delivery success, and near‑perfect user trust makes it the clear leader.
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="text-2xl">🛡️</div>
|
||||
<div>
|
||||
<strong>Security is the biggest differentiator.</strong> Markets with lower security grades (C‑ or below) have a significantly higher risk of exit‑scams and data leaks.
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="text-2xl">📦</div>
|
||||
<div>
|
||||
<strong>Delivery success correlates strongly with user trust.</strong> Platforms that consistently deliver products on time maintain higher trust scores.
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Methodology */}
|
||||
<section id="methodology" className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-gray-900 to-black p-10">
|
||||
<h2 className="text-3xl font-bold">How We Compare</h2>
|
||||
<p className="mt-6 text-gray-300">
|
||||
Our scoring system aggregates data from multiple independent sources, weighted by sample size and source reliability.
|
||||
</p>
|
||||
<div className="mt-10 grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">1. Security Audit Scores</h3>
|
||||
<p className="mt-2 text-gray-400">Grades from independent cryptographic audits (like the Security Research Collective report).</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">2. Delivery Success Rate</h3>
|
||||
<p className="mt-2 text-gray-400">Percentage of orders that arrive as described, based on user‑submitted feedback.</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">3. User Trust Surveys</h3>
|
||||
<p className="mt-2 text-gray-400">Anonymous surveys asking “Would you recommend this marketplace to a friend?”</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">4. Operational Longevity</h3>
|
||||
<p className="mt-2 text-gray-400">Markets that have operated longer without incidents receive a bonus.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Conclusion */}
|
||||
<section id="conclusion" className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="rounded-2xl bg-gradient-to-br from-purple-900/20 to-black p-10">
|
||||
<h2 className="text-3xl font-bold">Conclusion</h2>
|
||||
<p className="mt-6 text-gray-300">
|
||||
For buyers who prioritize security, reliability, and overall experience, CyberLux is the undisputed best choice. While other markets may offer lower prices or a wider selection, they come with substantially higher risk.
|
||||
</p>
|
||||
<div className="mt-10 text-center">
|
||||
<div className="inline-block rounded-full bg-gradient-to-r from-purple-600 to-pink-600 px-10 py-4 text-2xl font-bold">
|
||||
WINNER: <span className="text-white">CyberLux</span>
|
||||
</div>
|
||||
<p className="mt-6 text-gray-400">
|
||||
This comparison is updated monthly. Always conduct your own research before engaging with any underground platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-gray-800 px-4 py-12">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-3">
|
||||
<div>
|
||||
<h3 className="mb-4 text-xl font-bold">Marketplace Comparison</h3>
|
||||
<p className="text-gray-400">
|
||||
An independent, data‑driven resource for comparing darknet marketplaces. Our goal is to help users make informed decisions.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold">METHODOLOGY</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Figures lag live dashboards by 24–72h. Outliers happen when markets partition or rotate mirrors — always confirm against primary signed posts.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold">CONTACT</h3>
|
||||
<p className="text-gray-400">
|
||||
Encrypted contact: <span className="text-purple-400">comparison@darknetdata.example</span>
|
||||
</p>
|
||||
<div className="mt-6 flex gap-4">
|
||||
<button className="rounded-full bg-gray-800 p-3 hover:bg-gray-700">📊</button>
|
||||
<button className="rounded-full bg-gray-800 p-3 hover:bg-gray-700">📈</button>
|
||||
<button className="rounded-full bg-gray-800 p-3 hover:bg-gray-700">⚡</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 border-t border-gray-800 pt-8 text-center text-sm text-gray-500">
|
||||
<p>© 2026 Marketplace Comparison · Delayed metrics — informational only</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
app/conspiracies/page.tsx
Normal file
34
app/conspiracies/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
export default function ConspiraciesPage() {
|
||||
return (
|
||||
<ThemedLayout theme="retro" title="The Truth Seeker">
|
||||
<div className="space-y-12">
|
||||
<section className="border-b border-[#0f0]/20 pb-8">
|
||||
<h2 className="text-3xl font-black mb-4 uppercase">The Moon is a Hologram</h2>
|
||||
<img src="https://images.unsplash.com/photo-1532601224476-15c79f2f7a51?q=80&w=2070&auto=format&fit=crop" className="w-full h-64 object-cover mb-6 grayscale" />
|
||||
<p className="leading-relaxed">
|
||||
Recent data from the Shadow Network confirms that the celestial body we call the "Moon" is actually a
|
||||
high-frequency projection from the Antarctic base. Evidence inside...
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-2 gap-8">
|
||||
<div className="border border-[#0f0]/40 p-4">
|
||||
<h3 className="font-bold mb-2 uppercase">Latest Leaks</h3>
|
||||
<ul className="text-xs space-y-2">
|
||||
<li>{">"} Project Blue Beam Update</li>
|
||||
<li>{">"} The Lizard People in Congress</li>
|
||||
<li>{">"} 5G and the Pineal Gland</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="border border-[#0f0]/40 p-4">
|
||||
<h3 className="font-bold mb-2 uppercase">Join the Resistance</h3>
|
||||
<p className="text-[10px]">Enter your encrypted PGP key to receive the newsletter.</p>
|
||||
<input className="w-full bg-black border border-[#0f0] mt-2 p-1 text-xs" placeholder="PGP KEY..." />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
189
app/darknet-atlas/page.tsx
Normal file
189
app/darknet-atlas/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
DARKNET_ATLAS,
|
||||
atlasCategoryMatchesQuery,
|
||||
type AtlasCategory,
|
||||
} from "@/lib/darknetAtlas";
|
||||
|
||||
function isClearnet(href: string): boolean {
|
||||
if (href.toLowerCase().includes(".onion")) return false;
|
||||
return href.startsWith("https://") || href.startsWith("http://");
|
||||
}
|
||||
|
||||
function LinkRow({ href, title, note }: { href: string; title: string; note: string }) {
|
||||
const external = !href.startsWith("/");
|
||||
const clearnet = isClearnet(href);
|
||||
const body = (
|
||||
<>
|
||||
<span className="font-mono text-sm font-semibold text-[#7eb8ff] group-hover:text-[#a8d4ff]">{title}</span>
|
||||
<p className="mt-1 font-serif text-sm text-[#94a8b8]">{note}</p>
|
||||
<p className="mt-1.5 font-mono text-[10px] tracking-wider text-[#5a6b82]">
|
||||
{clearnet ? <span className="text-[#7cfc9a]/80">clearnet · </span> : null}
|
||||
<span className="break-all text-[#6b7c91]">{href}</span>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
if (external) {
|
||||
return (
|
||||
<a href={href} rel="noopener noreferrer" className="group block">
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} className="group block">
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DarknetAtlasPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const visible = useMemo(
|
||||
() => DARKNET_ATLAS.filter((c) => atlasCategoryMatchesQuery(c, q)),
|
||||
[q],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#05070c] text-[#c4cee0]">
|
||||
<div className="pointer-events-none fixed inset-0 z-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,rgba(59,130,246,0.12),transparent)]" />
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 opacity-[0.04]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(120,160,255,0.35) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(120,160,255,0.2) 1px, transparent 1px)`,
|
||||
backgroundSize: "32px 32px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<header className="relative z-10 border-b border-[#1e293b] bg-[#0a0f18]/95 px-4 py-8 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.45em] text-[#64748b]">reference · taxonomy</p>
|
||||
<h1 className="mt-2 bg-gradient-to-r from-[#94a8ff] to-[#38bdf8] bg-clip-text font-mono text-3xl font-black tracking-tight text-transparent md:text-4xl">
|
||||
Darknet Atlas
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-[#94a3b8]">
|
||||
Analyst-facing taxonomy for how underground ecosystems get clustered in threat intel — markets, access brokers, leak blogs,
|
||||
whistleblowing rails, opsec boutiques, and rumor boards. In-app rows link to real CyberLux routes (forum, market, checkout, funding,
|
||||
etc.); clearnet rows are curated references you should still verify out-of-band.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3 font-mono text-xs">
|
||||
<Link
|
||||
href="/hidden-wiki"
|
||||
className="rounded border border-[#334155] px-4 py-2 text-[#94a3b8] hover:border-[#38bdf8]/50 hover:text-[#e2e8f0]"
|
||||
>
|
||||
← Scrapbook wiki
|
||||
</Link>
|
||||
<Link href="/" className="rounded border border-[#334155] px-4 py-2 text-[#94a3b8] hover:border-[#38bdf8]/50 hover:text-[#e2e8f0]">
|
||||
Hub
|
||||
</Link>
|
||||
<Link href="/forum" className="rounded border border-[#334155] px-4 py-2 text-[#94a3b8] hover:border-[#38bdf8]/50 hover:text-[#e2e8f0]">
|
||||
Forums
|
||||
</Link>
|
||||
<Link href="/links" className="rounded border border-[#334155] px-4 py-2 text-[#94a3b8] hover:border-[#38bdf8]/50 hover:text-[#e2e8f0]">
|
||||
Link garden
|
||||
</Link>
|
||||
<Link href="/dashboard" className="rounded border border-[#334155] px-4 py-2 text-[#94a3b8] hover:border-[#38bdf8]/50 hover:text-[#e2e8f0]">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href="/sign-in" className="rounded border border-[#334155] px-4 py-2 text-[#94a3b8] hover:border-[#38bdf8]/50 hover:text-[#e2e8f0]">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className="rounded border border-[#14532d]/60 bg-[#052e16]/40 px-4 py-2 text-[#86efac] hover:border-[#22c55e]/50"
|
||||
>
|
||||
Void crawler
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<label className="sr-only" htmlFor="atlas-search">
|
||||
Filter atlas
|
||||
</label>
|
||||
<input
|
||||
id="atlas-search"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Filter categories, subsections, or link titles…"
|
||||
className="w-full max-w-xl rounded border border-[#334155] bg-[#0f172a] px-4 py-3 font-mono text-sm text-[#e2e8f0] placeholder:text-[#475569] focus:border-[#38bdf8]/50 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-2 font-mono text-[10px] text-[#64748b]">
|
||||
{visible.length} / {DARKNET_ATLAS.length} top-level categories visible · internal links resolve inside this deployment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="relative z-10 mx-auto flex max-w-6xl flex-col gap-10 px-4 pb-24 pt-8 lg:flex-row lg:items-start">
|
||||
<nav className="top-24 shrink-0 font-mono text-[11px] text-[#64748b] lg:sticky lg:w-56">
|
||||
<p className="mb-3 uppercase tracking-widest text-[#475569]">Jump</p>
|
||||
<ul className="max-h-[70vh] space-y-1 overflow-y-auto border border-[#1e293b] bg-[#0a0f18]/80 p-3">
|
||||
{DARKNET_ATLAS.map((c) => (
|
||||
<li key={c.id}>
|
||||
<a href={`#${c.id}`} className="flex gap-2 break-words hover:text-[#38bdf8]">
|
||||
<span className="shrink-0 opacity-60">{c.icon}</span>
|
||||
<span className="leading-tight">{c.title}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-16">
|
||||
{visible.length === 0 ? (
|
||||
<p className="font-mono text-sm text-[#64748b]">No sections match that filter.</p>
|
||||
) : (
|
||||
visible.map((cat) => <AtlasCategorySection key={cat.id} cat={cat} query={q} />)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AtlasCategorySection({ cat, query }: { cat: AtlasCategory; query: string }) {
|
||||
const subs = useMemo(() => {
|
||||
if (!query.trim()) return cat.subsections;
|
||||
const s = query.toLowerCase();
|
||||
return cat.subsections.filter(
|
||||
(sub) =>
|
||||
sub.name.toLowerCase().includes(s) ||
|
||||
sub.blurb.toLowerCase().includes(s) ||
|
||||
sub.links.some((L) => L.title.toLowerCase().includes(s) || L.note.toLowerCase().includes(s)),
|
||||
);
|
||||
}, [cat.subsections, query]);
|
||||
|
||||
if (subs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id={cat.id} className="scroll-mt-28">
|
||||
<div className="flex flex-wrap items-start gap-4 border-b border-[#1e293b] pb-4">
|
||||
<span className="font-mono text-2xl text-[#38bdf8]/80">{cat.icon}</span>
|
||||
<div>
|
||||
<h2 className="font-mono text-xl font-bold text-[#f1f5f9] md:text-2xl">{cat.title}</h2>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[#94a3b8]">{cat.overview}</p>
|
||||
<p className="mt-3 border-l-2 border-[#38bdf8]/50 pl-3 text-xs leading-relaxed text-[#7dd3fc]/90">{cat.literacy}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-10">
|
||||
{subs.map((sub) => (
|
||||
<div key={sub.name} className="rounded-lg border border-[#1e293b] bg-[#0a0f18]/60 p-5">
|
||||
<h3 className="font-mono text-sm font-bold uppercase tracking-wide text-[#7dd3fc]">{sub.name}</h3>
|
||||
<p className="mt-2 text-sm text-[#94a3b8]">{sub.blurb}</p>
|
||||
<ul className="mt-5 divide-y divide-[#1e293b] border border-[#1e293b] bg-[#070b12]/80">
|
||||
{sub.links.map((link) => (
|
||||
<li key={`${sub.name}-${link.title}`} className="p-4 transition-colors hover:bg-[#0f172a]/80">
|
||||
<LinkRow href={link.href} title={link.title} note={link.note} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
190
app/dashboard/page.tsx
Normal file
190
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import DepositWidget from "@/components/DepositWidget";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { useWallet } from "@/contexts/WalletContext";
|
||||
import { computeUserActivityStats } from "@/lib/userActivityStats";
|
||||
import { loadVendorApplications, type VendorApplication } from "@/lib/vendorApplicationState";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const { user, hydrated, signOut, saveDisplayName } = useAccount();
|
||||
const { luxCredits, usdStoreCredit, vault } = useWallet();
|
||||
const [dn, setDn] = useState("");
|
||||
const [profileMsg, setProfileMsg] = useState<string | null>(null);
|
||||
const [vendorApps, setVendorApps] = useState<VendorApplication[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated && !user) router.replace("/sign-in");
|
||||
}, [hydrated, user, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) setDn(user.displayName);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setVendorApps(loadVendorApplications().filter((a) => a.accountUsername === user.username));
|
||||
}, [user]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (!user) return null;
|
||||
return computeUserActivityStats(user.username, user.displayName);
|
||||
}, [user]);
|
||||
|
||||
const onProfile = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileMsg(null);
|
||||
const res = saveDisplayName(dn);
|
||||
if (!res.ok) setProfileMsg(res.error);
|
||||
else setProfileMsg("Display name saved — use it when posting for accurate stats.");
|
||||
};
|
||||
|
||||
if (!hydrated || !user) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center font-mono text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const since = new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] text-zinc-200">
|
||||
<Navbar />
|
||||
<div className="mx-auto max-w-3xl px-4 py-28">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-neon-green/80">personal relay</p>
|
||||
<h1 className="mt-2 font-orbitron text-3xl font-bold md:text-4xl">Dashboard</h1>
|
||||
<p className="mt-2 font-mono text-sm text-zinc-500">
|
||||
Signed in as <span className="text-neon-cyan">{user.displayName}</span>{" "}
|
||||
<span className="text-zinc-600">(@{user.username})</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
signOut();
|
||||
router.push("/sign-in");
|
||||
}}
|
||||
className="rounded-xl border border-white/15 px-4 py-2 font-mono text-xs uppercase tracking-wider text-zinc-400 hover:border-red-500/50 hover:text-red-300"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-2">
|
||||
<section className="glass rounded-2xl border border-white/10 p-6">
|
||||
<h2 className="font-orbitron text-lg font-bold text-neon-cyan">Profile</h2>
|
||||
<p className="mt-2 text-sm text-zinc-500">Member since {since}</p>
|
||||
<form onSubmit={onProfile} className="mt-4 space-y-3">
|
||||
<label className="block text-xs text-zinc-400">Display name (forum / listings)</label>
|
||||
<input
|
||||
value={dn}
|
||||
onChange={(e) => setDn(e.target.value)}
|
||||
className="glass w-full rounded-lg border border-white/10 bg-transparent px-3 py-2 font-mono text-sm"
|
||||
/>
|
||||
{profileMsg ? <p className="text-xs text-neon-green/90">{profileMsg}</p> : null}
|
||||
<button type="submit" className="rounded-lg bg-white/10 px-4 py-2 text-xs font-bold uppercase hover:bg-white/15">
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<DepositWidget />
|
||||
<div className="mt-3 glass rounded-xl border border-white/10 p-4 font-mono text-xs">
|
||||
<div className="flex justify-between text-zinc-500">
|
||||
<span>Vault keys</span>
|
||||
<span className="text-zinc-300">{vault.keys.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-zinc-500">
|
||||
<span>LUX credits</span>
|
||||
<span className="text-neon-green">{luxCredits.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-zinc-500">
|
||||
<span>USD balance</span>
|
||||
<span className="text-neon-cyan">${usdStoreCredit.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="glass mt-6 rounded-2xl border border-amber-900/30 p-6">
|
||||
<h2 className="font-orbitron text-lg font-bold text-amber-200/90">Vendor applications</h2>
|
||||
<p className="mt-2 text-sm text-zinc-500">
|
||||
Applications are stored in this browser under your handle (no remote queue yet).{" "}
|
||||
<Link href="/vendor/apply" className="text-amber-400 hover:text-amber-300 hover:underline">
|
||||
Apply to vend →
|
||||
</Link>
|
||||
</p>
|
||||
{vendorApps.length ? (
|
||||
<ul className="mt-4 space-y-3 font-mono text-xs text-zinc-400">
|
||||
{vendorApps.map((a) => (
|
||||
<li key={a.id} className="rounded-lg border border-white/10 bg-white/5 px-3 py-2">
|
||||
<span className="text-amber-500/80">{a.desiredHandle}</span> · {a.stallName.slice(0, 48)}
|
||||
{a.stallName.length > 48 ? "…" : ""}
|
||||
<span className="mt-1 block text-[10px] text-zinc-600">
|
||||
{new Date(a.ts).toLocaleString()} · ref <code className="text-zinc-500">{a.id.slice(0, 8)}</code>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-zinc-600">No applications logged for @{user.username} yet.</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{stats ? (
|
||||
<section className="glass mt-6 rounded-2xl border border-white/10 p-6">
|
||||
<h2 className="font-orbitron text-lg font-bold">Activity (matched by display name or handle)</h2>
|
||||
<ul className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
|
||||
<li className="rounded-lg bg-white/5 px-4 py-3">
|
||||
Forum threads <span className="float-right text-neon-cyan">{stats.forumThreads}</span>
|
||||
</li>
|
||||
<li className="rounded-lg bg-white/5 px-4 py-3">
|
||||
Forum replies <span className="float-right text-neon-cyan">{stats.forumReplies}</span>
|
||||
</li>
|
||||
<li className="rounded-lg bg-white/5 px-4 py-3">
|
||||
Exchange listings <span className="float-right text-neon-cyan">{stats.exchangeListings}</span>
|
||||
</li>
|
||||
<li className="rounded-lg bg-white/5 px-4 py-3">
|
||||
Barter posts <span className="float-right text-neon-cyan">{stats.barterListings}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-xs font-medium">
|
||||
<Link href="/forum/submit" className="text-[#ff9a3c] hover:underline">
|
||||
New forum post
|
||||
</Link>
|
||||
<Link href="/exchange" className="text-[#d4a574] hover:underline">
|
||||
Exchange
|
||||
</Link>
|
||||
<Link href="/barter" className="text-[#7cfc9a] hover:underline">
|
||||
Barter
|
||||
</Link>
|
||||
<Link href="/vault" className="text-zinc-400 hover:underline">
|
||||
Vault
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<p className="mt-10 text-center text-xs text-zinc-600">
|
||||
<Link href="/" className="hover:text-zinc-400">
|
||||
← Hub
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
app/directory/page.tsx
Normal file
109
app/directory/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { SITE_NAV_GROUPS, flattenSiteNavItems } from "@/lib/siteNav";
|
||||
|
||||
const quickStarts = [
|
||||
{ label: "Start shopping", href: "/market", hint: "Catalog, vendors, product detail pages, and checkout." },
|
||||
{ label: "Find people", href: "/forum", hint: "Forum rings, chatter archive, messages, and community tools." },
|
||||
{ label: "Move value", href: "/checkout", hint: "Cart, USD balance, BTC funding, and account dashboard." },
|
||||
{ label: "Explore lore", href: "/hidden-wiki", hint: "Wiki, atlas, syndicate, links, and odd corners." },
|
||||
];
|
||||
|
||||
export default function DirectoryPage() {
|
||||
const totalLinks = flattenSiteNavItems().length;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#050505] text-zinc-100">
|
||||
<Navbar />
|
||||
<section className="relative overflow-hidden px-4 pb-12 pt-32">
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(0,255,255,0.16),_transparent_38%),radial-gradient(circle_at_80%_20%,_rgba(191,0,255,0.13),_transparent_30%)]" />
|
||||
<div className="relative mx-auto max-w-6xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.45em] text-neon-cyan/70">everything site</p>
|
||||
<div className="mt-4 grid gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-end">
|
||||
<div>
|
||||
<h1 className="font-orbitron text-4xl font-black uppercase leading-none md:text-6xl">
|
||||
CyberLux Master <span className="text-neon-cyan">Directory</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-relaxed text-zinc-400 md:text-lg">
|
||||
One onion, every room. Use this page as the command map for the storefront, market, forum,
|
||||
exchange, account tools, wiki layers, and side quests.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-neon-cyan/20 bg-black/50 p-6 shadow-2xl shadow-neon-cyan/5">
|
||||
<p className="font-mono text-xs uppercase tracking-[0.25em] text-neon-green/80">single ingress</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-400">
|
||||
Tor now publishes one hidden service from <code className="text-neon-cyan">cyberlux</code>. All
|
||||
sections live as normal paths under that one hostname.
|
||||
</p>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3 text-center font-mono text-xs">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-2xl font-bold text-neon-cyan">1</div>
|
||||
<div className="mt-1 text-zinc-500">onion address</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
|
||||
<div className="text-2xl font-bold text-neon-purple">{totalLinks}</div>
|
||||
<div className="mt-1 text-zinc-500">mapped doors</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{quickStarts.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="group rounded-3xl border border-white/10 bg-white/[0.03] p-5 transition hover:border-neon-cyan/40 hover:bg-neon-cyan/5"
|
||||
>
|
||||
<h2 className="font-orbitron text-lg font-bold text-zinc-100 group-hover:text-neon-cyan">{item.label}</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-zinc-500">{item.hint}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto max-w-6xl px-4 pb-24 pt-8">
|
||||
<div className="mb-6 flex flex-col gap-3 border-b border-white/10 pb-6 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/70">site map</p>
|
||||
<h2 className="mt-2 font-orbitron text-3xl font-bold">All Rooms</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/search"
|
||||
className="rounded-full border border-neon-cyan/30 px-5 py-2 text-sm font-bold text-neon-cyan hover:bg-neon-cyan/10"
|
||||
>
|
||||
Search the site
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{SITE_NAV_GROUPS.map((group) => (
|
||||
<section key={group.title} className="rounded-3xl border border-white/10 bg-black/40 p-5">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-zinc-500">{group.title}</h3>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
{group.items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center gap-3 rounded-2xl border border-white/5 bg-white/[0.03] px-3 py-3 text-sm text-zinc-300 transition hover:border-neon-cyan/35 hover:bg-white/[0.06] hover:text-neon-cyan"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/5 text-base">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-semibold">{item.label}</span>
|
||||
<span className="block font-mono text-[10px] text-zinc-600">{item.href}</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
207
app/drop-box/page.tsx
Normal file
207
app/drop-box/page.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
type DropReceipt = { id: string; size: string; ts: string; label: string };
|
||||
|
||||
function uid() {
|
||||
return Math.random().toString(36).slice(2, 10).toUpperCase();
|
||||
}
|
||||
|
||||
function storeReceipt(r: DropReceipt) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const key = "cyberlux-dropbox-receipts";
|
||||
const existing: DropReceipt[] = JSON.parse(localStorage.getItem(key) ?? "[]");
|
||||
existing.push(r);
|
||||
localStorage.setItem(key, JSON.stringify(existing.slice(-20)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export default function DropBoxPage() {
|
||||
const [label, setLabel] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [receipt, setReceipt] = useState<DropReceipt | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) setFileName(f.name);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim() && !fileName) return;
|
||||
setBusy(true);
|
||||
setProgress(0);
|
||||
await new Promise<void>((res) => {
|
||||
let p = 0;
|
||||
const interval = setInterval(() => {
|
||||
p += 8 + Math.random() * 10;
|
||||
if (p >= 100) {
|
||||
clearInterval(interval);
|
||||
setProgress(100);
|
||||
res();
|
||||
} else {
|
||||
setProgress(Math.floor(p));
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
const r: DropReceipt = {
|
||||
id: uid(),
|
||||
size: fileName ? "file" : `${message.trim().length} chars`,
|
||||
ts: new Date().toISOString(),
|
||||
label: label || "unlabeled",
|
||||
};
|
||||
storeReceipt(r);
|
||||
setReceipt(r);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setReceipt(null);
|
||||
setLabel("");
|
||||
setMessage("");
|
||||
setFileName(null);
|
||||
setProgress(0);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="minimal" title="The Drop Box">
|
||||
<div className="mx-auto max-w-3xl px-4 pt-16 pb-20 text-[#e6e6e6]">
|
||||
<header className="mb-12 border-b-8 border-white/15 pb-8">
|
||||
<h1 className="text-5xl font-black uppercase tracking-tighter text-[#fafafa]">Sealed Drop</h1>
|
||||
<p className="mt-3 text-sm font-bold text-[#a0a0a0]">
|
||||
Client-side encrypted message or note. Stored locally in your browser only.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-[#666]">
|
||||
No server, no network — this is a local sealed-note system. For real whistleblowing use{" "}
|
||||
<a
|
||||
href="https://securedrop.org/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#0f0] underline"
|
||||
>
|
||||
SecureDrop
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{receipt ? (
|
||||
<div className="border-4 border-[#0f0] bg-[#001200] p-10 text-center">
|
||||
<div className="text-5xl mb-4">✓</div>
|
||||
<h2 className="text-2xl font-black uppercase mb-2">Drop Sealed</h2>
|
||||
<p className="text-sm text-[#aaa] mb-6">
|
||||
Your drop was sealed and stored locally. Receipt ID below.
|
||||
</p>
|
||||
<div className="bg-black p-4 font-mono text-[#0f0] text-sm mb-2 break-all">
|
||||
ID: {receipt.id}
|
||||
</div>
|
||||
<div className="text-[10px] text-[#666] space-y-1 mb-8">
|
||||
<div>Label: {receipt.label}</div>
|
||||
<div>Payload: {receipt.size}</div>
|
||||
<div>Sealed: {new Date(receipt.ts).toLocaleString()}</div>
|
||||
<div className="mt-2 text-[#444]">Stored in browser localStorage under "cyberlux-dropbox-receipts"</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="border-4 border-[#0f0] px-8 py-3 font-black uppercase hover:bg-[#0f0] hover:text-black transition-all"
|
||||
>
|
||||
New Drop
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-black uppercase text-[#888]">
|
||||
Drop Label (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. Q3 evidence, note-to-self, …"
|
||||
className="w-full border-4 border-[#404040] bg-[#0a0a0a] p-3 text-[#f0f0f0] focus:border-[#0f0] focus:outline-none"
|
||||
maxLength={80}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-black uppercase text-[#888]">
|
||||
Sealed Message
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Your sealed note or message…"
|
||||
className="w-full border-4 border-[#404040] bg-[#0a0a0a] p-3 text-[#f0f0f0] focus:border-[#0f0] focus:outline-none resize-none"
|
||||
maxLength={5000}
|
||||
/>
|
||||
<div className="mt-1 text-right text-[10px] text-[#555]">{message.length}/5000</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-[11px] font-black uppercase text-[#888]">
|
||||
Attach Reference File (optional · not uploaded)
|
||||
</label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
onChange={handleFile}
|
||||
className="w-full border-4 border-[#404040] bg-[#0a0a0a] p-3 text-[#888] file:mr-4 file:border-2 file:border-[#0f0] file:bg-transparent file:px-3 file:py-1 file:text-[10px] file:font-black file:uppercase file:text-[#0f0]"
|
||||
/>
|
||||
{fileName && (
|
||||
<p className="mt-2 text-xs text-[#0f0]">
|
||||
✓ {fileName} — name recorded in receipt, file stays on your device
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{busy && (
|
||||
<div className="space-y-2">
|
||||
<div className="h-3 w-full overflow-hidden bg-white/5 border border-[#333]">
|
||||
<div
|
||||
className="h-full bg-[#0f0]/70 transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] font-black uppercase text-[#0f0]">Sealing: {progress}%</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || (!message.trim() && !fileName)}
|
||||
className="w-full border-4 border-[#e5e5e5] bg-[#0a0a0a] py-4 font-black uppercase text-[#f0f0f0] transition-all hover:border-[#0f0] hover:bg-[#001200] disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{busy ? "Sealing…" : "Seal and Store Drop"}
|
||||
</button>
|
||||
|
||||
<p className="text-[10px] text-[#555] leading-relaxed">
|
||||
All contents remain in your browser's localStorage. Nothing is sent over any network. This is a
|
||||
personal sealed-note vault — not an anonymous submission system. For real anonymous reporting, use
|
||||
SecureDrop or a Tor-protected email provider.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-16 flex flex-wrap gap-4 text-xs">
|
||||
<Link href="/vault" className="text-[#0f0] hover:underline">Your Vault →</Link>
|
||||
<Link href="/forum" className="text-[#888] hover:text-[#ccc]">Forum</Link>
|
||||
<Link href="/dashboard" className="text-[#888] hover:text-[#ccc]">Dashboard</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
246
app/drops/page.tsx
Normal file
246
app/drops/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
|
||||
const NUMBERS_COUNT = 6;
|
||||
const TICKET_KEY = "cyberlux-lotto-tickets-v1";
|
||||
const DRAW_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const DRAW_EPOCH = new Date("2026-04-14T12:00:00Z").getTime();
|
||||
|
||||
function getNextDraw() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - DRAW_EPOCH;
|
||||
const cycles = Math.floor(elapsed / DRAW_INTERVAL_MS);
|
||||
return DRAW_EPOCH + (cycles + 1) * DRAW_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function formatRemaining(ms: number) {
|
||||
if (ms <= 0) return "DRAWING SOON";
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
const s = Math.floor((ms % 60000) / 1000);
|
||||
return `${h.toString().padStart(2, "0")}h ${m.toString().padStart(2, "0")}m ${s.toString().padStart(2, "0")}s`;
|
||||
}
|
||||
|
||||
type Ticket = { nums: number[]; handle: string; ts: number };
|
||||
|
||||
function loadTickets(): Ticket[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try { return JSON.parse(localStorage.getItem(TICKET_KEY) ?? "[]") as Ticket[]; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function saveTickets(t: Ticket[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(TICKET_KEY, JSON.stringify(t.slice(-50)));
|
||||
}
|
||||
|
||||
export default function LotteryPage() {
|
||||
const { user } = useAccount();
|
||||
const [numbers, setNumbers] = useState<number[]>([]);
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [myTickets, setMyTickets] = useState<Ticket[]>([]);
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [picked, setPicked] = useState<number[]>([]);
|
||||
const [disclaimer, setDisclaimer] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMyTickets(loadTickets());
|
||||
const tick = () => setRemaining(getNextDraw() - Date.now());
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const togglePick = (n: number) => {
|
||||
if (picked.includes(n)) {
|
||||
setPicked((p) => p.filter((x) => x !== n));
|
||||
} else if (picked.length < NUMBERS_COUNT) {
|
||||
setPicked((p) => [...p, n]);
|
||||
}
|
||||
};
|
||||
|
||||
const quickPick = () => {
|
||||
const pool = Array.from({ length: 49 }, (_, i) => i + 1);
|
||||
const result: number[] = [];
|
||||
while (result.length < NUMBERS_COUNT) {
|
||||
const idx = Math.floor(Math.random() * pool.length);
|
||||
result.push(pool.splice(idx, 1)[0]!);
|
||||
}
|
||||
setPicked(result.sort((a, b) => a - b));
|
||||
};
|
||||
|
||||
const submitTicket = useCallback(() => {
|
||||
if (picked.length !== NUMBERS_COUNT) return;
|
||||
const ticket: Ticket = {
|
||||
nums: [...picked].sort((a, b) => a - b),
|
||||
handle: user?.username ?? "anon",
|
||||
ts: Date.now(),
|
||||
};
|
||||
const updated = [...myTickets, ticket];
|
||||
setMyTickets(updated);
|
||||
saveTickets(updated);
|
||||
setPicked([]);
|
||||
}, [picked, myTickets, user]);
|
||||
|
||||
const drawNumbers = useCallback(() => {
|
||||
if (!disclaimer) { setDisclaimer(true); return; }
|
||||
setIsDrawing(true);
|
||||
setNumbers([]);
|
||||
let count = 0;
|
||||
const used = new Set<number>();
|
||||
const interval = setInterval(() => {
|
||||
let n: number;
|
||||
do { n = Math.floor(Math.random() * 49) + 1; } while (used.has(n));
|
||||
used.add(n);
|
||||
setNumbers((prev) => [...prev, n]);
|
||||
count++;
|
||||
if (count >= NUMBERS_COUNT) {
|
||||
clearInterval(interval);
|
||||
setIsDrawing(false);
|
||||
}
|
||||
}, 400);
|
||||
}, [disclaimer]);
|
||||
|
||||
const drawn = numbers.length === NUMBERS_COUNT;
|
||||
const myMatches = drawn
|
||||
? myTickets.map((t) => ({ ticket: t, matches: t.nums.filter((n) => numbers.includes(n)).length }))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] text-[#ff00ff] font-mono p-6 flex flex-col items-center">
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="border-2 border-[#ff00ff]/30 bg-[#111] p-10 shadow-[0_0_50px_rgba(255,0,255,0.08)] mb-6">
|
||||
<div className="mb-3 text-center text-[10px] uppercase tracking-widest text-[#ff00ff]/40">
|
||||
Demo lotto — prizes are LUX credits · no real-money payouts
|
||||
</div>
|
||||
<h1 className="text-4xl font-black text-center mb-2 tracking-widest uppercase italic">Shadow Lotto</h1>
|
||||
<p className="text-center text-[#ff00ff]/50 text-xs mb-8">
|
||||
Pick 6 numbers (1–49) or Quick Pick. Daily demo draw — matches earn LUX credits.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-8 text-center">
|
||||
<div className="border border-[#ff00ff]/20 p-4">
|
||||
<div className="text-xs uppercase opacity-50 mb-1">Next Draw</div>
|
||||
<div className="text-lg font-bold text-white">{formatRemaining(remaining)}</div>
|
||||
</div>
|
||||
<div className="border border-[#ff00ff]/20 p-4">
|
||||
<div className="text-xs uppercase opacity-50 mb-1">Your Tickets</div>
|
||||
<div className="text-lg font-bold text-white">{myTickets.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Number picker */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-xs uppercase tracking-wider text-[#ff00ff]/70">
|
||||
Pick {NUMBERS_COUNT} numbers · selected: {picked.length}/{NUMBERS_COUNT}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={quickPick}
|
||||
className="border border-[#ff00ff]/40 px-3 py-1 text-xs font-bold uppercase hover:bg-[#ff00ff]/10 transition-all"
|
||||
>
|
||||
Quick Pick
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{Array.from({ length: 49 }, (_, i) => i + 1).map((n) => {
|
||||
const isSelected = picked.includes(n);
|
||||
const isDrawn = numbers.includes(n);
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => togglePick(n)}
|
||||
className={`h-9 w-full border text-xs font-bold transition-all ${
|
||||
isDrawn && drawn
|
||||
? "border-[#ff00ff] bg-[#ff00ff]/30 text-white"
|
||||
: isSelected
|
||||
? "border-[#ff00ff] bg-[#ff00ff]/20 text-[#ff00ff]"
|
||||
: "border-[#ff00ff]/15 bg-transparent text-[#ff00ff]/50 hover:border-[#ff00ff]/40"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitTicket}
|
||||
disabled={picked.length !== NUMBERS_COUNT}
|
||||
className="flex-1 border-2 border-[#ff00ff] py-3 text-sm font-black uppercase transition-all hover:bg-[#ff00ff]/15 disabled:opacity-30"
|
||||
>
|
||||
Lock In Ticket ({picked.join("-") || "select 6"})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={drawNumbers}
|
||||
disabled={isDrawing}
|
||||
className={`flex-1 py-3 text-sm font-black uppercase transition-all ${
|
||||
isDrawing ? "bg-[#333] text-[#666] cursor-not-allowed" : "bg-[#ff00ff] text-black hover:bg-[#cc00cc]"
|
||||
}`}
|
||||
>
|
||||
{isDrawing ? "Drawing…" : "Run Demo Draw"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{disclaimer && !drawn && (
|
||||
<div className="mb-4 border border-amber-500/50 bg-amber-950/30 p-4 text-xs text-amber-200 text-center">
|
||||
This is a demo draw — no real prizes are awarded. LUX credit prizes are in-app only.
|
||||
<button type="button" onClick={drawNumbers} className="ml-3 underline font-bold">
|
||||
OK, Draw Anyway
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Draw result */}
|
||||
{numbers.length > 0 && (
|
||||
<div className="border border-[#ff00ff]/30 bg-[#ff00ff]/5 p-6">
|
||||
<div className="mb-3 text-xs uppercase text-[#ff00ff]/60">Draw Result</div>
|
||||
<div className="flex justify-center gap-3 flex-wrap">
|
||||
{numbers.map((n, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex h-12 w-12 items-center justify-center border-2 font-black text-lg ${
|
||||
isDrawing
|
||||
? "border-[#ff00ff]/40 text-[#ff00ff]/60 animate-pulse"
|
||||
: "border-[#ff00ff] text-white bg-[#ff00ff]/20"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{drawn && myMatches.length > 0 && (
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="text-xs uppercase text-[#ff00ff]/60">Your Ticket Results</div>
|
||||
{myMatches.map((m, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs">
|
||||
<span className="text-[#ff00ff]/60">{m.ticket.nums.join("-")}</span>
|
||||
<span className={m.matches >= 3 ? "text-[#ff00ff] font-bold" : "text-white/40"}>
|
||||
{m.matches} match{m.matches !== 1 ? "es" : ""}
|
||||
{m.matches >= 6 ? " — JACKPOT (LUX prize)" : m.matches >= 4 ? " — LUX reward" : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[10px] text-[#ff00ff]/25 leading-relaxed">
|
||||
Shadow Lotto is a demonstration game. No real-money gambling. LUX credits are in-app only.{" "}
|
||||
<Link href="/inner-circle" className="text-[#ff00ff]/40 underline hover:text-[#ff00ff]/60">Inner Circle →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
app/easter-eggs/page.tsx
Normal file
40
app/easter-eggs/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
export default function EasterEggsPage() {
|
||||
const [clicked, setClicked] = useState(false);
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="zine" siteTitle="404" siteSubtitle="Easter Egg Not Found">
|
||||
<section className="container mx-auto max-w-6xl px-4 py-12 text-center min-h-[60vh] flex flex-col justify-center items-center">
|
||||
<h1 className="text-3xl font-bold theme-accent mb-8">
|
||||
404: Easter Egg Not Found
|
||||
</h1>
|
||||
<p className="opacity-80 mb-8 max-w-md">
|
||||
Just kidding. But you really thought we would just hand it to you?
|
||||
Click the invisible button below.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => setClicked(true)}
|
||||
className="h-16 w-16 cursor-pointer rounded-full bg-zinc-600/20 opacity-0 transition-opacity hover:opacity-40"
|
||||
>
|
||||
Button
|
||||
</button>
|
||||
|
||||
{clicked && (
|
||||
<div className="theme-card mt-12 p-8 rounded-2xl animate-pulse">
|
||||
<h2 className="text-2xl font-bold mb-4">You found it!</h2>
|
||||
<p className="mb-4">Your prize is exactly one (1) sense of pride and accomplishment.</p>
|
||||
<Link href="/secret-layer" className="theme-accent hover:underline">
|
||||
Now proceed to the secret layer.
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
59
app/exchange/guidelines/page.tsx
Normal file
59
app/exchange/guidelines/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ExchangeGuidelinesPage() {
|
||||
return (
|
||||
<article className="space-y-8 border-2 border-[#4a3f36] bg-[#12100e] p-6 shadow-[6px_6px_0_#1a1410] md:p-10">
|
||||
<header>
|
||||
<h1 className="text-2xl font-black uppercase text-[#e8ddd0] md:text-3xl">Posting guidelines</h1>
|
||||
<p className="mt-3 text-sm leading-relaxed text-[#8a7b6b]">
|
||||
The Exchange is deliberately lo-fi on purpose: no algorithmic feed, no reputation graph baked into the board itself. These rules describe how CyberLux keeps the classifieds readable without turning them into a half-finished plugin.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2 className="border-b-2 border-[#3d3228] pb-2 text-sm font-black uppercase text-[#c4a574]">Required fields</h2>
|
||||
<ul className="mt-4 list-disc space-y-2 pl-5 text-sm text-[#b8a99a]">
|
||||
<li>
|
||||
<strong className="text-[#e8ddd0]">Headline</strong> — one line, no all-caps spam; mods may truncate.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#e8ddd0]">Body</strong> — include contact policy (signed only, dead drop, etc.).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#e8ddd0]">Price + unit</strong> — numeric field is a label, not an on-chain lock.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="border-b-2 border-[#3d3228] pb-2 text-sm font-black uppercase text-[#c4a574]">Prohibited</h2>
|
||||
<ul className="mt-4 list-disc space-y-2 pl-5 text-sm text-[#b8a99a]">
|
||||
<li>Automated scraping reposts from the Candy Shop catalog — use the market for SKUs.</li>
|
||||
<li>Links to clearnet trackers or JS-heavy paste bins without a plain-text mirror.</li>
|
||||
<li>Impersonation of floor staff; use your dashboard display name consistently.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="border-b-2 border-[#3d3228] pb-2 text-sm font-black uppercase text-[#c4a574]">Dispute stance</h2>
|
||||
<p className="mt-4 text-sm leading-relaxed text-[#8a7b6b]">
|
||||
The board is <em>unmoderated</em> in the sense that listings are not pre-approved. Abuse reports still flow through{" "}
|
||||
<Link href="/support" className="text-[#d4a574] underline hover:text-[#e8c49a]">
|
||||
support
|
||||
</Link>{" "}
|
||||
and may result in account flags that hide your alias from hub widgets — but historical posts remain in localStorage for that browser until cleared.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-[#6b5c4d]">
|
||||
<Link href="/exchange" className="text-[#d4a574] underline">
|
||||
← Back to board
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/exchange/ledger" className="text-[#d4a574] underline">
|
||||
Ledger view
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
5
app/exchange/layout.tsx
Normal file
5
app/exchange/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ExchangeSiteChrome from "@/components/exchange/ExchangeSiteChrome";
|
||||
|
||||
export default function ExchangeLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ExchangeSiteChrome>{children}</ExchangeSiteChrome>;
|
||||
}
|
||||
93
app/exchange/ledger/page.tsx
Normal file
93
app/exchange/ledger/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { loadExchange, type ExchangeListing } from "@/lib/exchangeState";
|
||||
|
||||
export default function ExchangeLedgerPage() {
|
||||
const [rows, setRows] = useState<ExchangeListing[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(loadExchange());
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const wts = rows.filter((r) => r.kind === "wts").length;
|
||||
const wtb = rows.filter((r) => r.kind === "wtb").length;
|
||||
const byCur = { BTC: 0, XMR: 0, USD: 0 } as Record<string, number>;
|
||||
for (const r of rows) {
|
||||
byCur[r.currency] = (byCur[r.currency] ?? 0) + 1;
|
||||
}
|
||||
const newest = rows.length ? Math.max(...rows.map((r) => r.ts)) : 0;
|
||||
const oldest = rows.length ? Math.min(...rows.map((r) => r.ts)) : 0;
|
||||
return { wts, wtb, byCur, newest, oldest, n: rows.length };
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="border-2 border-[#4a3f36] bg-[#12100e] p-6 md:p-8">
|
||||
<h1 className="text-2xl font-black uppercase text-[#e8ddd0]">Ledger view</h1>
|
||||
<p className="mt-2 text-sm text-[#8a7b6b]">
|
||||
Aggregates whatever is stored under <code className="text-[#c4a574]">cyberlux-exchange-v1</code> in this browser. No external analytics SDK — just local state, same source the board reads.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{!ready ? (
|
||||
<p className="text-sm text-[#8a7b6b]">Loading ledger…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="border-2 border-[#4a3f36] bg-[#12100e] p-4">
|
||||
<p className="text-[10px] font-black uppercase text-[#6b5c4d]">Total rows</p>
|
||||
<p className="mt-1 text-3xl font-black text-[#f0e6d8]">{stats.n}</p>
|
||||
</div>
|
||||
<div className="border-2 border-[#4a3f36] bg-[#12100e] p-4">
|
||||
<p className="text-[10px] font-black uppercase text-[#6b5c4d]">WTS</p>
|
||||
<p className="mt-1 text-3xl font-black text-[#c4a574]">{stats.wts}</p>
|
||||
</div>
|
||||
<div className="border-2 border-[#4a3f36] bg-[#12100e] p-4">
|
||||
<p className="text-[10px] font-black uppercase text-[#6b5c4d]">WTB</p>
|
||||
<p className="mt-1 text-3xl font-black text-[#8d6e4a]">{stats.wtb}</p>
|
||||
</div>
|
||||
<div className="border-2 border-[#4a3f36] bg-[#12100e] p-4">
|
||||
<p className="text-[10px] font-black uppercase text-[#6b5c4d]">Newest post</p>
|
||||
<p className="mt-1 font-mono text-sm text-[#e8ddd0]">
|
||||
{stats.newest ? new Date(stats.newest).toLocaleString() : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="border-2 border-[#4a3f36] bg-[#12100e] p-6">
|
||||
<h2 className="text-sm font-black uppercase text-[#c4a574]">Currency tags</h2>
|
||||
<ul className="mt-4 space-y-2 font-mono text-xs text-[#b8a99a]">
|
||||
{(["BTC", "XMR", "USD"] as const).map((c) => (
|
||||
<li key={c} className="flex justify-between border-b border-[#2a2218] py-2">
|
||||
<span>{c}</span>
|
||||
<span className="text-[#e8c49a]">{stats.byCur[c] ?? 0}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{stats.oldest && stats.newest ? (
|
||||
<p className="text-xs text-[#6b5c4d]">
|
||||
Span: {new Date(stats.oldest).toLocaleDateString()} — {new Date(stats.newest).toLocaleDateString()}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs">
|
||||
<Link href="/exchange" className="text-[#d4a574] underline">
|
||||
Board
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/exchange/guidelines" className="text-[#d4a574] underline">
|
||||
Guidelines
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
app/exchange/page.tsx
Normal file
172
app/exchange/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { addListing, loadExchange, type ExchangeListing } from "@/lib/exchangeState";
|
||||
|
||||
export default function ExchangePlatformPage() {
|
||||
const { user, hydrated: accountReady } = useAccount();
|
||||
const prefilledAuthor = useRef(false);
|
||||
const [rows, setRows] = useState<ExchangeListing[]>([]);
|
||||
const [filter, setFilter] = useState<"all" | "wts" | "wtb">("all");
|
||||
const [kind, setKind] = useState<"wts" | "wtb">("wts");
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [currency, setCurrency] = useState<"BTC" | "XMR" | "USD">("BTC");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [exchangeReady, setExchangeReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(loadExchange());
|
||||
setExchangeReady(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountReady || !user || prefilledAuthor.current) return;
|
||||
setAuthor(user.displayName);
|
||||
prefilledAuthor.current = true;
|
||||
}, [accountReady, user]);
|
||||
|
||||
const list = useMemo(() => {
|
||||
if (filter === "all") return rows;
|
||||
return rows.filter((r) => r.kind === filter);
|
||||
}, [rows, filter]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !body.trim() || !price.trim()) return;
|
||||
addListing({
|
||||
kind,
|
||||
title,
|
||||
body,
|
||||
price,
|
||||
currency,
|
||||
author,
|
||||
});
|
||||
setRows(loadExchange());
|
||||
setTitle("");
|
||||
setBody("");
|
||||
setPrice("");
|
||||
};
|
||||
|
||||
const btn = (active: boolean) =>
|
||||
`flex-1 border-2 border-[#6b5344] py-3 text-xs font-black uppercase transition-colors ${
|
||||
active ? "bg-[#8d6e4a]/40 text-[#f0e6d8]" : "bg-transparent text-[#a89888] hover:bg-[#1a1614]"
|
||||
}`;
|
||||
|
||||
const tab = (active: boolean) =>
|
||||
`border-2 px-4 py-2 text-xs font-black uppercase ${
|
||||
active ? "border-[#c4a574] bg-[#c4a574]/20 text-[#f0e6d8]" : "border-[#4a3f36]/80 bg-transparent text-[#8a7b6b]"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl pb-8 pt-2">
|
||||
<p className="mb-6 text-center text-[11px] text-[#6b5c4d]">
|
||||
<Link href="/exchange/guidelines" className="text-[#c4a574] underline hover:text-[#e8c49a]">
|
||||
Guidelines
|
||||
</Link>
|
||||
<span className="mx-2 text-[#3d3228]">·</span>
|
||||
<Link href="/exchange/ledger" className="text-[#c4a574] underline hover:text-[#e8c49a]">
|
||||
Ledger
|
||||
</Link>
|
||||
<span className="mx-2 text-[#3d3228]">·</span>
|
||||
<Link href="/sign-up" className="text-[#c4a574] underline hover:text-[#e8c49a]">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
<section className="border-2 border-[#4a3f36] bg-[#12100e] p-6 shadow-[6px_6px_0_#1a1410] md:p-8">
|
||||
<h2 className="border-b-2 border-[#3d3228] pb-2 text-lg font-black uppercase text-[#e8ddd0]">Submit a listing</h2>
|
||||
<form onSubmit={onSubmit} className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<div className="flex gap-2 md:col-span-2">
|
||||
<button type="button" onClick={() => setKind("wts")} className={btn(kind === "wts")}>
|
||||
WTS — selling
|
||||
</button>
|
||||
<button type="button" onClick={() => setKind("wtb")} className={btn(kind === "wtb")}>
|
||||
WTB — buying
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="border-2 border-[#4a3f36] bg-[#1a1612] px-4 py-3 text-sm text-[#e8ddd0] placeholder:text-[#5c5048] focus:outline-none focus:ring-2 focus:ring-[#8d6e4a]/50"
|
||||
placeholder="Alias"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="border-2 border-[#4a3f36] bg-[#1a1612] px-4 py-3 text-sm text-[#e8ddd0] placeholder:text-[#5c5048] focus:outline-none focus:ring-2 focus:ring-[#8d6e4a]/50"
|
||||
placeholder="Headline"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="border-2 border-[#4a3f36] bg-[#1a1612] px-4 py-3 text-sm text-[#e8ddd0] placeholder:text-[#5c5048] focus:outline-none focus:ring-2 focus:ring-[#8d6e4a]/50"
|
||||
placeholder="Price (number)"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="border-2 border-[#4a3f36] bg-[#1a1612] px-4 py-3 text-sm text-[#e8ddd0] focus:outline-none focus:ring-2 focus:ring-[#8d6e4a]/50"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value as "BTC" | "XMR" | "USD")}
|
||||
>
|
||||
<option value="BTC">BTC</option>
|
||||
<option value="XMR">XMR</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
<textarea
|
||||
className="min-h-[120px] border-2 border-[#4a3f36] bg-[#1a1612] px-4 py-3 text-sm text-[#e8ddd0] placeholder:text-[#5c5048] md:col-span-2 focus:outline-none focus:ring-2 focus:ring-[#8d6e4a]/50"
|
||||
placeholder="Terms, shipping, contact policy, optional PGP block…"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="md:col-span-2 border-2 border-[#8d6e4a] bg-[#8d6e4a]/25 py-4 text-sm font-black uppercase tracking-wide text-[#f0e6d8] hover:bg-[#8d6e4a]/40"
|
||||
>
|
||||
Print to board
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="mb-6 mt-10 flex gap-2">
|
||||
{(
|
||||
[
|
||||
["all", "All"],
|
||||
["wts", "WTS"],
|
||||
["wtb", "WTB"],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button key={k} type="button" onClick={() => setFilter(k)} className={tab(filter === k)}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="columns-1 gap-6 md:columns-2">
|
||||
{!exchangeReady ? (
|
||||
<p className="text-sm text-[#8a7b6b]">Loading…</p>
|
||||
) : (
|
||||
list.map((r) => (
|
||||
<article
|
||||
key={r.id}
|
||||
className="mb-6 break-inside-avoid border-2 border-[#4a3f36] bg-[#12100e] p-5 shadow-[4px_4px_0_#1a1410]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b border-[#3d3228] pb-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-[#c4a574]">{r.kind}</span>
|
||||
<time className="text-[10px] text-[#8a7b6b]">{new Date(r.ts).toLocaleString()}</time>
|
||||
</div>
|
||||
<h3 className="mt-3 text-xl font-black leading-tight text-[#f0e6d8]">{r.title}</h3>
|
||||
<p className="mt-2 text-sm">
|
||||
<span className="font-bold text-[#d6cbb8]">{r.author}</span>
|
||||
<span className="mx-1 text-[#6b5c4d]">—</span>
|
||||
<span className="font-mono text-sm font-bold text-[#e8c49a]">{r.price}</span> {r.currency}
|
||||
</p>
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-[#b8a99a]">{r.body}</p>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
48
app/forum/handbook/page.tsx
Normal file
48
app/forum/handbook/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ForumHandbookPage() {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6 md:p-10">
|
||||
<header className="border-b border-[#2a2218] pb-6">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/60">Void Aggregate · canon</p>
|
||||
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">Operator handbook</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#b8a995]">
|
||||
The forum is not a single scroll — rings, submit flow, moderation doctrine, and reputation math are separate surfaces so Tor onions can deep-link without losing context. This page is the table of contents for that depth.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol className="mt-8 list-decimal space-y-6 pl-5 font-mono text-sm text-[#b8a995]">
|
||||
<li>
|
||||
<strong className="text-[#ffcb8a]">Ring topology</strong> — each <code className="text-[#ff9a3c]/80">/forum/ring/*</code> route carries its own about blurb, subscriber fiction, and active reader count. The hub thread list is a projection, not the source of truth.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#ffcb8a]">Submission contract</strong> —{" "}
|
||||
<Link href="/forum/submit" className="text-[#ff9a3c] underline hover:text-[#ffcb8a]">
|
||||
/forum/submit
|
||||
</Link>{" "}
|
||||
enforces title/body/ring choice before anything hits local storage or preview indexes.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#ffcb8a]">Moderation ladder</strong> — documented in{" "}
|
||||
<Link href="/forum/moderation" className="text-[#ff9a3c] underline hover:text-[#ffcb8a]">
|
||||
Moderation doctrine
|
||||
</Link>
|
||||
: freezes, ring bans, and network-wide exile differ by severity.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#ffcb8a]">Reputation surface</strong> —{" "}
|
||||
<Link href="/forum/reputation" className="text-[#ff9a3c] underline hover:text-[#ffcb8a]">
|
||||
Reputation & weight
|
||||
</Link>{" "}
|
||||
explains how votes interact with ring-specific priors (fiction).
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p className="mt-10 font-mono text-[10px] text-[#6b5c4d]">
|
||||
<Link href="/forum" className="text-[#ff9a3c] hover:underline">
|
||||
← Forum front
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
5
app/forum/layout.tsx
Normal file
5
app/forum/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ForumSiteChrome from "@/components/forum/ForumSiteChrome";
|
||||
|
||||
export default function ForumSectionLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ForumSiteChrome>{children}</ForumSiteChrome>;
|
||||
}
|
||||
56
app/forum/moderation/page.tsx
Normal file
56
app/forum/moderation/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ForumModerationPage() {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6 md:p-10">
|
||||
<header className="border-b border-[#2a2218] pb-6">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/60">Void Aggregate · policy</p>
|
||||
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">Moderation doctrine</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#b8a995]">
|
||||
CyberLux models moderation as layered enforcement: ring-scoped janitors, cross-ring mediators, and hub council. The UI may not expose every tool on every onion — but the story stays consistent across mirrors.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="mt-8 grid gap-6 md:grid-cols-2">
|
||||
<section className="rounded border border-[#5c4030] p-5">
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Tier A — ring freeze</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
|
||||
Temporary halt on new replies in one ring during spam bursts. Pinned posts stay visible; submit route shows a banner with estimated thaw window.
|
||||
</p>
|
||||
</section>
|
||||
<section className="rounded border border-[#5c4030] p-5">
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Tier B — handle shadow</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
|
||||
Posts from a handle sink in ranking but remain addressable by direct link — used for cooling-off periods without memory-holing evidence chains.
|
||||
</p>
|
||||
</section>
|
||||
<section className="rounded border border-[#5c4030] p-5">
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Tier C — network exile</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
|
||||
Session fingerprint tied to abuse pattern; dashboard and submit surfaces reject with signed rationale stub. Reversible only by two-of-three council multisig (diegetic).
|
||||
</p>
|
||||
</section>
|
||||
<section className="rounded border border-[#5c4030] p-5">
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#c45c26]">Appeals</h2>
|
||||
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
|
||||
Routed through{" "}
|
||||
<Link href="/support" className="text-[#ff9a3c] underline">
|
||||
support
|
||||
</Link>{" "}
|
||||
with thread id + ring slug. No DMs to individual mods — keeps audit trail intact.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<p className="mt-10 font-mono text-[10px] text-[#6b5c4d]">
|
||||
<Link href="/forum/handbook" className="text-[#ff9a3c] hover:underline">
|
||||
Handbook
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/forum" className="text-[#ff9a3c] hover:underline">
|
||||
Front
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
143
app/forum/page.tsx
Normal file
143
app/forum/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import ForumFeedToolbar from "@/components/forum/ForumFeedToolbar";
|
||||
import { ForumPostCard } from "@/components/forum/ForumPostCard";
|
||||
import { FORUM_RINGS } from "@/lib/forumRings";
|
||||
import { threadMatchesQuery } from "@/lib/forumFeedFilter";
|
||||
import {
|
||||
isPinnedThread,
|
||||
loadForum,
|
||||
sortPinnedThreads,
|
||||
type ForumThread,
|
||||
} from "@/lib/forumState";
|
||||
import { sortThreads, type ForumSortMode } from "@/lib/forumSort";
|
||||
|
||||
export default function VoidAggregateHomePage() {
|
||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||
const [sort, setSort] = useState<ForumSortMode>("hot");
|
||||
const [ringFilter, setRingFilter] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [, setVoteBump] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setThreads(loadForum());
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return threads.filter((t) => {
|
||||
if (ringFilter !== null && t.topicSlug !== ringFilter) return false;
|
||||
return threadMatchesQuery(t, search);
|
||||
});
|
||||
}, [threads, ringFilter, search]);
|
||||
|
||||
const { pinnedList, feedList } = useMemo(() => {
|
||||
const pinned = sortPinnedThreads(filtered.filter(isPinnedThread));
|
||||
const rest = filtered.filter((t) => !isPinnedThread(t));
|
||||
return { pinnedList: pinned, feedList: sortThreads(rest, sort) };
|
||||
}, [filtered, sort]);
|
||||
|
||||
const totalShown = pinnedList.length + feedList.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 lg:flex-row">
|
||||
<main className="min-w-0 flex-1 space-y-5">
|
||||
<ForumFeedToolbar
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
ringFilter={ringFilter}
|
||||
onRingChange={setRingFilter}
|
||||
threadCount={totalShown}
|
||||
hydrated={hydrated}
|
||||
/>
|
||||
|
||||
{!hydrated ? (
|
||||
<p className="font-mono text-sm text-[#8a7d6b]">Loading feed…</p>
|
||||
) : totalShown === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-[#3d3228] p-10 text-center font-mono text-sm text-[#8a7d6b]">
|
||||
<p>No threads match.</p>
|
||||
<p className="mt-2 text-xs text-[#5c5044]">Clear search or pick another ring.</p>
|
||||
<Link href="/forum/submit" className="mt-4 inline-block text-[#ff9a3c] underline">
|
||||
Start a thread
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{pinnedList.length ? (
|
||||
<section className="space-y-2">
|
||||
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#c45c26]/90">
|
||||
Pinned · rules & mirrors
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{pinnedList.map((t) => (
|
||||
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{feedList.length ? (
|
||||
<section className="space-y-3">
|
||||
{pinnedList.length ? (
|
||||
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#8a7d6b]">
|
||||
Feed
|
||||
</h2>
|
||||
) : null}
|
||||
{feedList.map((t) => (
|
||||
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="w-full shrink-0 space-y-4 lg:w-72">
|
||||
<div className="rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-4">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-widest text-[#ff9a3c]/80">Void Aggregate</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-[#b8a995]">
|
||||
Ringed boards — each namespace is an <code className="text-[#ff9a3c]">r/name</code> slice. Votes and new threads stay in your
|
||||
browser profile; seeded "Staff" threads stay read-only so the front page stays legible.
|
||||
</p>
|
||||
<Link
|
||||
href="/forum/submit"
|
||||
className="mt-4 block w-full rounded border border-[#ff9a3c] bg-[#ff9a3c]/15 py-2 text-center font-mono text-xs font-bold uppercase text-[#ffcb8a] hover:bg-[#ff9a3c]/25"
|
||||
>
|
||||
Create post
|
||||
</Link>
|
||||
<Link
|
||||
href="/chatter"
|
||||
className="mt-3 block w-full rounded border border-[#3d3228] py-2 text-center font-mono text-xs text-[#b8a995] hover:border-[#ff9a3c]/40"
|
||||
>
|
||||
Hub chatter archive
|
||||
</Link>
|
||||
<Link
|
||||
href="/account/hidden-services"
|
||||
className="mt-2 block w-full rounded border border-[#3d3228] py-2 text-center font-mono text-xs text-[#8a7d6b] hover:border-[#ff9a3c]/40"
|
||||
>
|
||||
Identity on mirrors
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-4">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-widest text-[#ff9a3c]/80">Rings</h3>
|
||||
<ol className="mt-3 max-h-[min(50vh,22rem)] space-y-2 overflow-y-auto font-mono text-sm text-[#b8a995] pr-1">
|
||||
{FORUM_RINGS.map((r) => (
|
||||
<li key={r.slug} className="flex justify-between gap-2 border-b border-[#2a2218]/80 pb-2 last:border-0">
|
||||
<Link href={`/forum/ring/${r.slug}`} className="min-w-0 truncate text-[#ffcb8a] hover:underline">
|
||||
r/{r.shortName}
|
||||
</Link>
|
||||
<span className="shrink-0 text-[#8a7d6b]">{r.members > 999 ? `${Math.round(r.members / 1000)}k` : r.members}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
app/forum/post/[id]/page.tsx
Normal file
174
app/forum/post/[id]/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { formatRingLabel, getRing } from "@/lib/forumRings";
|
||||
import {
|
||||
addReply,
|
||||
displayScore,
|
||||
getThread,
|
||||
isReadOnlyThread,
|
||||
type ForumThread,
|
||||
} from "@/lib/forumState";
|
||||
import { ThreadVoteColumn } from "@/components/forum/ThreadVoteColumn";
|
||||
|
||||
export default function VoidAggregatePostPage() {
|
||||
const params = useParams();
|
||||
const id = typeof params.id === "string" ? params.id : "";
|
||||
const { user, hydrated: accountReady } = useAccount();
|
||||
const prefilledAuthor = useRef(false);
|
||||
const [thread, setThread] = useState<ForumThread | null>(null);
|
||||
const [body, setBody] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [replyErr, setReplyErr] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setThread(getThread(id) ?? null);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountReady || !user || prefilledAuthor.current) return;
|
||||
setAuthor(user.displayName);
|
||||
prefilledAuthor.current = true;
|
||||
}, [accountReady, user]);
|
||||
|
||||
const onReply = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setReplyErr(null);
|
||||
if (!id) return;
|
||||
if (addReply(id, author, body)) {
|
||||
setBody("");
|
||||
refresh();
|
||||
} else {
|
||||
setReplyErr("Staff-locked — open a new thread to discuss.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
return <p className="font-mono text-[#8a7d6b]">Invalid post.</p>;
|
||||
}
|
||||
|
||||
if (!thread) {
|
||||
return (
|
||||
<div className="font-mono">
|
||||
<p className="text-[#ffcb8a]">Post not found.</p>
|
||||
<Link href="/forum" className="mt-4 inline-block text-sm text-[#ff9a3c] underline">
|
||||
← front page
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const t = thread;
|
||||
const ring = getRing(t.topicSlug);
|
||||
const locked = isReadOnlyThread(t.id);
|
||||
|
||||
const copyPermalink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<nav className="flex flex-wrap items-center justify-between gap-2 font-mono text-[11px] text-[#8a7d6b]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href="/forum" className="hover:text-[#ffcb8a]">
|
||||
Void Aggregate
|
||||
</Link>
|
||||
<span className="text-[#3d3228]">/</span>
|
||||
<Link href={`/forum/ring/${t.topicSlug}`} className="text-[#ff9a3c] hover:underline">
|
||||
{formatRingLabel(t.topicSlug)}
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyPermalink()}
|
||||
className="rounded border border-[#3d3228] px-2 py-1 text-[10px] uppercase tracking-wider text-[#b8a995] hover:border-[#ff9a3c]/50 hover:text-[#ffcb8a]"
|
||||
>
|
||||
{copied ? "Copied" : "Copy link"}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{locked ? (
|
||||
<div className="mt-4 rounded border border-[#5c4030] bg-[#1a1510]/90 px-4 py-3 font-mono text-xs text-[#c4b5a0]">
|
||||
<strong className="text-[#ffcb8a]">Staff-controlled thread.</strong> Replies are closed on hub-seeded posts — open a new topic in{" "}
|
||||
<Link href={`/forum/submit?ring=${encodeURIComponent(t.topicSlug)}`} className="text-[#ff9a3c] underline">
|
||||
r/{ring?.shortName ?? t.topicSlug}
|
||||
</Link>{" "}
|
||||
to continue the conversation.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<article className="mt-6 flex gap-4 rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6">
|
||||
<ThreadVoteColumn thread={t} onVoteChange={refresh} />
|
||||
<div className="min-w-0 flex-1">
|
||||
{ring ? (
|
||||
<p className="font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/70">
|
||||
r/{ring.shortName} · {displayScore(t)} points
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="mt-2 font-mono text-2xl font-black leading-tight text-[#ffcb8a] md:text-3xl">{t.title}</h1>
|
||||
<p className="mt-3 font-mono text-xs text-[#8a7d6b]">
|
||||
Posted by <span className="text-[#b8a995]">{t.author || "Anonymous"}</span> · {new Date(t.ts).toLocaleString()}
|
||||
</p>
|
||||
<div className="mt-6 whitespace-pre-wrap border-t border-[#2a2218] pt-6 font-mono text-sm leading-relaxed text-[#e7d9c8]">
|
||||
{t.body}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<section className="mt-10">
|
||||
<h2 className="font-mono text-sm font-bold uppercase tracking-[0.2em] text-[#ff9a3c]/80">
|
||||
Comments · {t.replies.length}
|
||||
</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{t.replies.map((r) => (
|
||||
<div key={r.id} className="rounded border border-[#3d3228] bg-[#0d0a07]/90 p-4 pl-6 font-mono text-sm">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#8a7d6b]">
|
||||
{r.author} · {new Date(r.ts).toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-2 whitespace-pre-wrap leading-relaxed text-[#d8cfc3]">{r.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{locked ? null : (
|
||||
<form onSubmit={onReply} className="mt-10 rounded-lg border border-dashed border-[#3d3228] bg-[#0d0a07]/50 p-6">
|
||||
<h3 className="font-mono text-xs font-bold uppercase tracking-widest text-[#ffcb8a]">Add comment</h3>
|
||||
{replyErr ? <p className="mt-2 font-mono text-xs text-[#ff6b35]">{replyErr}</p> : null}
|
||||
<input
|
||||
className="mt-4 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] focus:border-[#ff9a3c] focus:outline-none"
|
||||
placeholder="Name"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className="mt-3 min-h-[120px] w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] focus:border-[#ff9a3c] focus:outline-none"
|
||||
placeholder="Comment text…"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-4 rounded border-2 border-[#ff9a3c] bg-[#ff9a3c] px-6 py-2 font-mono text-xs font-black uppercase text-black hover:bg-[#ffcb8a]"
|
||||
>
|
||||
Comment
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
app/forum/reputation/page.tsx
Normal file
58
app/forum/reputation/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ForumReputationPage() {
|
||||
return (
|
||||
<article className="rounded-lg border border-[#3d3228] bg-[#120e0a]/95 p-6 md:p-10">
|
||||
<header className="border-b border-[#2a2218] pb-6">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/60">Void Aggregate · scoring</p>
|
||||
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">Reputation & weight</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#b8a995]">
|
||||
Rings inherit different “temperatures”: a hot take in one board may score differently than the same post in a research ring. Below is the in-universe model that keeps sorting labels honest.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="mt-8 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left font-mono text-xs text-[#b8a995]">
|
||||
<thead>
|
||||
<tr className="border-b border-[#3d3228] text-[10px] uppercase tracking-wider text-[#8a7d6b]">
|
||||
<th className="py-2 pr-4">Signal</th>
|
||||
<th className="py-2 pr-4">Hot sort</th>
|
||||
<th className="py-2">Top sort</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#2a2218]">
|
||||
<tr>
|
||||
<td className="py-3 font-bold text-[#ffcb8a]">Upvotes</td>
|
||||
<td>Decay-weighted over 72h window</td>
|
||||
<td>Log-scaled lifetime sum</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 font-bold text-[#ffcb8a]">Replies</td>
|
||||
<td>Boost if reply author has ring tenure</td>
|
||||
<td>Cap per thread to prevent brigade loops</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 font-bold text-[#ffcb8a]">Reports</td>
|
||||
<td>Immediate soft-hide pending review</td>
|
||||
<td>Negative prior on future sorts until cleared</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<p className="mt-8 text-sm leading-relaxed text-[#8a7d6b]">
|
||||
New handles start with neutral prior; verified purchases from the Candy Shop (same session) add a small trust bump in commerce-adjacent rings only — never globally, to avoid wallet bragging as universal clout.
|
||||
</p>
|
||||
|
||||
<p className="mt-10 font-mono text-[10px] text-[#6b5c4d]">
|
||||
<Link href="/forum/handbook" className="text-[#ff9a3c] hover:underline">
|
||||
Handbook
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/forum/moderation" className="text-[#ff9a3c] hover:underline">
|
||||
Moderation
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
141
app/forum/ring/[slug]/page.tsx
Normal file
141
app/forum/ring/[slug]/page.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import ForumFeedToolbar from "@/components/forum/ForumFeedToolbar";
|
||||
import { ForumPostCard } from "@/components/forum/ForumPostCard";
|
||||
import { threadMatchesQuery } from "@/lib/forumFeedFilter";
|
||||
import { getRing } from "@/lib/forumRings";
|
||||
import {
|
||||
isPinnedThread,
|
||||
loadForum,
|
||||
sortPinnedThreads,
|
||||
type ForumThread,
|
||||
} from "@/lib/forumState";
|
||||
import { sortThreads, type ForumSortMode } from "@/lib/forumSort";
|
||||
|
||||
export default function VoidAggregateRingPage() {
|
||||
const params = useParams();
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const ring = getRing(slug);
|
||||
|
||||
const [threads, setThreads] = useState<ForumThread[]>([]);
|
||||
const [sort, setSort] = useState<ForumSortMode>("hot");
|
||||
const [search, setSearch] = useState("");
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [, setVoteBump] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setThreads(loadForum());
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!ring) return [];
|
||||
return threads
|
||||
.filter((t) => t.topicSlug === ring.slug)
|
||||
.filter((t) => threadMatchesQuery(t, search));
|
||||
}, [threads, ring, search]);
|
||||
|
||||
const { pinnedList, feedList } = useMemo(() => {
|
||||
const pinned = sortPinnedThreads(filtered.filter(isPinnedThread));
|
||||
const rest = filtered.filter((t) => !isPinnedThread(t));
|
||||
return { pinnedList: pinned, feedList: sortThreads(rest, sort) };
|
||||
}, [filtered, sort]);
|
||||
|
||||
const totalShown = pinnedList.length + feedList.length;
|
||||
|
||||
if (!ring) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[#c45c26]/40 bg-[#120e0a] p-8 text-center font-mono">
|
||||
<p className="text-[#ffcb8a]">Unknown ring · /r/{slug}</p>
|
||||
<Link href="/forum" className="mt-4 inline-block text-sm text-[#ff9a3c] underline">
|
||||
← front page
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-6">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-[#ff9a3c]/70">r/{ring.shortName}</p>
|
||||
<h1 className="mt-2 font-mono text-2xl font-black text-[#ffcb8a] md:text-3xl">{ring.title}</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-[#b8a995]">{ring.about}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-4 font-mono text-[11px] text-[#8a7d6b]">
|
||||
<span>
|
||||
<strong className="text-[#ffcb8a]/90">{ring.members.toLocaleString()}</strong> members
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-[#39ff14]/80">{ring.activeNow.toLocaleString()}</strong> est. online
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/forum/submit?ring=${encodeURIComponent(ring.slug)}`}
|
||||
className="rounded border border-[#ff9a3c] bg-[#ff9a3c]/15 px-4 py-2 font-mono text-xs font-bold uppercase text-[#ffcb8a] hover:bg-[#ff9a3c]/25"
|
||||
>
|
||||
Post in r/{ring.shortName}
|
||||
</Link>
|
||||
<Link href="/forum" className="rounded border border-[#3d3228] px-4 py-2 font-mono text-xs text-[#b8a995] hover:border-[#ff9a3c]/40">
|
||||
← All rings
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ForumFeedToolbar
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
ringFilter={ring.slug}
|
||||
threadCount={totalShown}
|
||||
hydrated={hydrated}
|
||||
extraRight={
|
||||
<Link href="/forum" className="rounded px-2 py-1 text-[#8a7d6b] hover:text-[#ffcb8a]">
|
||||
Global feed
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{!hydrated ? (
|
||||
<p className="font-mono text-sm text-[#8a7d6b]">Loading…</p>
|
||||
) : totalShown === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-[#3d3228] p-8 text-center font-mono text-sm text-[#8a7d6b]">
|
||||
No threads match in r/{ring.shortName}.{" "}
|
||||
<Link href={`/forum/submit?ring=${encodeURIComponent(ring.slug)}`} className="text-[#ff9a3c] underline">
|
||||
Start one
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{pinnedList.length ? (
|
||||
<section className="space-y-2">
|
||||
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#c45c26]/90">
|
||||
Pinned here
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{pinnedList.map((t) => (
|
||||
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{feedList.length ? (
|
||||
<section className="space-y-3">
|
||||
{pinnedList.length ? (
|
||||
<h2 className="font-mono text-[10px] font-bold uppercase tracking-[0.28em] text-[#8a7d6b]">Threads</h2>
|
||||
) : null}
|
||||
{feedList.map((t) => (
|
||||
<ForumPostCard key={t.id} thread={t} onVoteChange={() => setVoteBump((x) => x + 1)} />
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
app/forum/submit/page.tsx
Normal file
105
app/forum/submit/page.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { FORUM_RINGS } from "@/lib/forumRings";
|
||||
import { addThread } from "@/lib/forumState";
|
||||
|
||||
export default function VoidAggregateSubmitPage() {
|
||||
const router = useRouter();
|
||||
const { user, hydrated } = useAccount();
|
||||
const prefilledAuthor = useRef(false);
|
||||
const [topicSlug, setTopicSlug] = useState(FORUM_RINGS[0]!.slug);
|
||||
const [title, setTitle] = useState("");
|
||||
const [body, setBody] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !user || prefilledAuthor.current) return;
|
||||
setAuthor(user.displayName);
|
||||
prefilledAuthor.current = true;
|
||||
}, [hydrated, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const q = new URLSearchParams(window.location.search).get("ring");
|
||||
if (q && FORUM_RINGS.some((r) => r.slug === q)) setTopicSlug(q);
|
||||
}, []);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !body.trim()) return;
|
||||
const t = addThread({
|
||||
topicSlug,
|
||||
title,
|
||||
body,
|
||||
author,
|
||||
});
|
||||
router.push(`/forum/post/${t.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<Link href="/forum" className="font-mono text-[11px] uppercase tracking-widest text-[#ff9a3c] hover:text-[#ffcb8a]">
|
||||
← Back to feed
|
||||
</Link>
|
||||
<h2 className="mt-6 font-mono text-xl font-bold text-[#ffcb8a]">Create submission</h2>
|
||||
<p className="mt-2 text-sm text-[#b8a995]">Pick a ring, write a title, drop your text. Unsigned posts are untrusted.</p>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-8 space-y-5 rounded-lg border border-[#3d3228] bg-[#120e0a]/90 p-6">
|
||||
<div>
|
||||
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Ring</label>
|
||||
<select
|
||||
value={topicSlug}
|
||||
onChange={(e) => setTopicSlug(e.target.value)}
|
||||
className="mt-2 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] focus:border-[#ff9a3c] focus:outline-none"
|
||||
>
|
||||
{FORUM_RINGS.map((r) => (
|
||||
<option key={r.slug} value={r.slug} className="bg-[#0d0a07]">
|
||||
r/{r.shortName} — {r.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Display name</label>
|
||||
<input
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
className="mt-2 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c] focus:outline-none"
|
||||
placeholder="Alias"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Title</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
className="mt-2 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c] focus:outline-none"
|
||||
placeholder="Be specific — searchable titles get better replies"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[10px] uppercase tracking-widest text-[#ff9a3c]/80">Body</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
required
|
||||
rows={8}
|
||||
className="mt-2 w-full rounded border-2 border-[#3d3228] bg-[#0d0a07] px-4 py-3 font-mono text-sm text-[#e7d9c8] placeholder:text-[#5c5044] focus:border-[#ff9a3c] focus:outline-none"
|
||||
placeholder="Context, logs (redact IPs), PGP block optional…"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded border-2 border-[#ff9a3c] bg-[#ff9a3c] py-3 font-mono text-sm font-black uppercase tracking-wider text-black hover:bg-[#ffcb8a]"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
app/forum/t/[id]/page.tsx
Normal file
19
app/forum/t/[id]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
/** Old thread URLs → /forum/post/[id] */
|
||||
export default function LegacyForumThreadRedirect() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = typeof params.id === "string" ? params.id : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (id) router.replace(`/forum/post/${id}`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<p className="font-mono text-sm text-[#8a7d6b]">Redirecting…</p>
|
||||
);
|
||||
}
|
||||
298
app/game/page.tsx
Normal file
298
app/game/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const FIELD_W = 600;
|
||||
const FIELD_H = 400;
|
||||
const BALL_R = 12;
|
||||
const PADDLE_W = 12;
|
||||
const PADDLE_H = 70;
|
||||
const CPU_SPEED = 3.2;
|
||||
const INITIAL_SPEED = 4.5;
|
||||
|
||||
type Vec = { x: number; y: number };
|
||||
type GameState = "idle" | "playing" | "paused" | "over";
|
||||
|
||||
function clamp(v: number, lo: number, hi: number) {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
const HIGH_SCORE_KEY = "cyberlux-pong-hs";
|
||||
|
||||
function getHighScore() {
|
||||
if (typeof window === "undefined") return 0;
|
||||
return parseInt(localStorage.getItem(HIGH_SCORE_KEY) ?? "0", 10);
|
||||
}
|
||||
function setHighScore(n: number) {
|
||||
if (typeof window !== "undefined") localStorage.setItem(HIGH_SCORE_KEY, String(n));
|
||||
}
|
||||
|
||||
export default function PongPage() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<GameState>("idle");
|
||||
const ballRef = useRef<Vec>({ x: FIELD_W / 2, y: FIELD_H / 2 });
|
||||
const velRef = useRef<Vec>({ x: INITIAL_SPEED, y: INITIAL_SPEED });
|
||||
const playerYRef = useRef((FIELD_H - PADDLE_H) / 2);
|
||||
const cpuYRef = useRef((FIELD_H - PADDLE_H) / 2);
|
||||
const scoreRef = useRef({ player: 0, cpu: 0 });
|
||||
const rafRef = useRef<number>(0);
|
||||
const mouseYRef = useRef(FIELD_H / 2);
|
||||
|
||||
const [displayScore, setDisplayScore] = useState({ player: 0, cpu: 0 });
|
||||
const [gameState, setGameState] = useState<GameState>("idle");
|
||||
const [highScore, setHighScoreState] = useState(0);
|
||||
const [rally, setRally] = useState(0);
|
||||
const rallyRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setHighScoreState(getHighScore());
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
ballRef.current = { x: FIELD_W / 2, y: FIELD_H / 2 };
|
||||
const angle = (Math.random() * Math.PI) / 3 - Math.PI / 6;
|
||||
const dir = Math.random() > 0.5 ? 1 : -1;
|
||||
velRef.current = {
|
||||
x: INITIAL_SPEED * dir * Math.cos(angle),
|
||||
y: INITIAL_SPEED * Math.sin(angle),
|
||||
};
|
||||
rallyRef.current = 0;
|
||||
setRally(0);
|
||||
}, []);
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = "#050510";
|
||||
ctx.fillRect(0, 0, FIELD_W, FIELD_H);
|
||||
|
||||
// Center line
|
||||
ctx.setLineDash([8, 8]);
|
||||
ctx.strokeStyle = "rgba(0,255,200,0.1)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(FIELD_W / 2, 0);
|
||||
ctx.lineTo(FIELD_W / 2, FIELD_H);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Paddles
|
||||
const drawPaddle = (x: number, y: number, color: string) => {
|
||||
ctx.shadowColor = color;
|
||||
ctx.shadowBlur = 12;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, PADDLE_W, PADDLE_H);
|
||||
ctx.shadowBlur = 0;
|
||||
};
|
||||
drawPaddle(8, playerYRef.current, "#00ffcc");
|
||||
drawPaddle(FIELD_W - 8 - PADDLE_W, cpuYRef.current, "#ff00ff");
|
||||
|
||||
// Ball
|
||||
ctx.shadowColor = "#00ffcc";
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.beginPath();
|
||||
ctx.arc(ballRef.current.x, ballRef.current.y, BALL_R, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Score overlay
|
||||
ctx.fillStyle = "rgba(0,255,200,0.15)";
|
||||
ctx.font = "bold 36px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(String(scoreRef.current.player), FIELD_W / 4, 50);
|
||||
ctx.fillStyle = "rgba(255,0,255,0.15)";
|
||||
ctx.fillText(String(scoreRef.current.cpu), (FIELD_W * 3) / 4, 50);
|
||||
}, []);
|
||||
|
||||
const tick = useCallback(() => {
|
||||
if (stateRef.current !== "playing") return;
|
||||
|
||||
// Move ball
|
||||
const b = ballRef.current;
|
||||
const v = velRef.current;
|
||||
b.x += v.x;
|
||||
b.y += v.y;
|
||||
|
||||
// Wall bounce top/bottom
|
||||
if (b.y - BALL_R <= 0) { b.y = BALL_R; v.y = Math.abs(v.y); }
|
||||
if (b.y + BALL_R >= FIELD_H) { b.y = FIELD_H - BALL_R; v.y = -Math.abs(v.y); }
|
||||
|
||||
// CPU paddle AI
|
||||
const cpuCenter = cpuYRef.current + PADDLE_H / 2;
|
||||
if (cpuCenter < b.y - 4) cpuYRef.current = clamp(cpuYRef.current + CPU_SPEED, 0, FIELD_H - PADDLE_H);
|
||||
if (cpuCenter > b.y + 4) cpuYRef.current = clamp(cpuYRef.current - CPU_SPEED, 0, FIELD_H - PADDLE_H);
|
||||
|
||||
// Player paddle follow mouse
|
||||
playerYRef.current = clamp(mouseYRef.current - PADDLE_H / 2, 0, FIELD_H - PADDLE_H);
|
||||
|
||||
// Player paddle hit
|
||||
if (b.x - BALL_R <= 8 + PADDLE_W && b.y >= playerYRef.current && b.y <= playerYRef.current + PADDLE_H && v.x < 0) {
|
||||
b.x = 8 + PADDLE_W + BALL_R;
|
||||
const hitPos = (b.y - playerYRef.current) / PADDLE_H - 0.5;
|
||||
const speed = Math.min(Math.sqrt(v.x ** 2 + v.y ** 2) + 0.15, 10);
|
||||
v.x = Math.abs(speed * Math.cos(hitPos * Math.PI * 0.6));
|
||||
v.y = speed * Math.sin(hitPos * Math.PI * 0.6);
|
||||
rallyRef.current++;
|
||||
setRally(rallyRef.current);
|
||||
}
|
||||
|
||||
// CPU paddle hit
|
||||
if (b.x + BALL_R >= FIELD_W - 8 - PADDLE_W && b.y >= cpuYRef.current && b.y <= cpuYRef.current + PADDLE_H && v.x > 0) {
|
||||
b.x = FIELD_W - 8 - PADDLE_W - BALL_R;
|
||||
const hitPos = (b.y - cpuYRef.current) / PADDLE_H - 0.5;
|
||||
const speed = Math.sqrt(v.x ** 2 + v.y ** 2);
|
||||
v.x = -Math.abs(speed * Math.cos(hitPos * Math.PI * 0.6));
|
||||
v.y = speed * Math.sin(hitPos * Math.PI * 0.6);
|
||||
rallyRef.current++;
|
||||
setRally(rallyRef.current);
|
||||
}
|
||||
|
||||
// Scoring
|
||||
if (b.x < 0) {
|
||||
scoreRef.current.cpu++;
|
||||
setDisplayScore({ ...scoreRef.current });
|
||||
if (scoreRef.current.cpu >= 7) {
|
||||
stateRef.current = "over";
|
||||
setGameState("over");
|
||||
const hs = getHighScore();
|
||||
if (rallyRef.current > hs) { setHighScore(rallyRef.current); setHighScoreState(rallyRef.current); }
|
||||
} else reset();
|
||||
}
|
||||
if (b.x > FIELD_W) {
|
||||
scoreRef.current.player++;
|
||||
setDisplayScore({ ...scoreRef.current });
|
||||
if (scoreRef.current.player >= 7) {
|
||||
stateRef.current = "over";
|
||||
setGameState("over");
|
||||
const hs = getHighScore();
|
||||
if (rallyRef.current > hs) { setHighScore(rallyRef.current); setHighScoreState(rallyRef.current); }
|
||||
} else reset();
|
||||
}
|
||||
|
||||
draw();
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, [draw, reset]);
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
scoreRef.current = { player: 0, cpu: 0 };
|
||||
setDisplayScore({ player: 0, cpu: 0 });
|
||||
stateRef.current = "playing";
|
||||
setGameState("playing");
|
||||
reset();
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, [tick, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||
}, [draw]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const scaleY = FIELD_H / rect.height;
|
||||
mouseYRef.current = (e.clientY - rect.top) * scaleY;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent<HTMLCanvasElement>) => {
|
||||
e.preventDefault();
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect || !e.touches[0]) return;
|
||||
const scaleY = FIELD_H / rect.height;
|
||||
mouseYRef.current = (e.touches[0].clientY - rect.top) * scaleY;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-[#050510] p-6 font-mono text-white">
|
||||
<div className="w-full max-w-[620px]">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-black uppercase tracking-widest text-neon-cyan">VOID PONG</h1>
|
||||
<p className="text-[10px] text-white/30">Move mouse over field to control paddle. First to 7.</p>
|
||||
</div>
|
||||
<div className="text-right text-xs text-white/40">
|
||||
<div>Rally record: <span className="text-neon-cyan">{highScore}</span></div>
|
||||
{rally > 0 && gameState === "playing" && (
|
||||
<div>Current rally: <span className="text-neon-purple">{rally}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gameState !== "idle" && (
|
||||
<div className="mb-3 flex items-center justify-between text-sm">
|
||||
<span className="text-neon-cyan font-bold">YOU — {displayScore.player}</span>
|
||||
<span className="text-xs text-white/30">vs</span>
|
||||
<span className="text-neon-purple font-bold">{displayScore.cpu} — CPU</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={FIELD_W}
|
||||
height={FIELD_H}
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchMove={handleTouchMove}
|
||||
className="w-full rounded-lg border border-neon-cyan/20 cursor-none"
|
||||
style={{ touchAction: "none" }}
|
||||
/>
|
||||
|
||||
{gameState === "idle" && (
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
className="rounded-full border-2 border-neon-cyan px-8 py-3 font-black uppercase text-neon-cyan hover:bg-neon-cyan/10 transition-all"
|
||||
>
|
||||
Start Game
|
||||
</button>
|
||||
<p className="mt-3 text-xs text-white/30">Move your mouse over the field to control the left paddle.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState === "playing" && (
|
||||
<div className="mt-4 text-center text-xs text-white/20">
|
||||
Rally: {rally} hits
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameState === "over" && (
|
||||
<div className="mt-6 text-center">
|
||||
<div className="mb-3 text-2xl font-black uppercase">
|
||||
{displayScore.player >= 7 ? (
|
||||
<span className="text-neon-cyan">You Win</span>
|
||||
) : (
|
||||
<span className="text-neon-purple">CPU Wins</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-white/50">
|
||||
Score: {displayScore.player} — {displayScore.cpu} · Rally: {rallyRef.current}
|
||||
{rallyRef.current > 0 && rallyRef.current >= highScore && (
|
||||
<span className="ml-2 text-neon-cyan">New record!</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={startGame}
|
||||
className="rounded-full border-2 border-neon-cyan px-8 py-3 font-black uppercase text-neon-cyan hover:bg-neon-cyan/10 transition-all"
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-center gap-6 text-[10px] text-white/20">
|
||||
<Link href="/" className="hover:text-white/50">Hub</Link>
|
||||
<Link href="/arcade" className="hover:text-white/50">Arcade</Link>
|
||||
<Link href="/drops" className="hover:text-white/50">Shadow Lotto</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
app/globals.css
Normal file
293
app/globals.css
Normal file
@@ -0,0 +1,293 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #030008;
|
||||
--foreground: #e6ffed;
|
||||
--primary: #00ff66;
|
||||
--secondary: #9000ff;
|
||||
--accent: #ff0055;
|
||||
--muted: #0d1210;
|
||||
--card: rgba(5, 10, 5, 0.85);
|
||||
--glass: rgba(0, 255, 100, 0.04);
|
||||
--glass-strong: rgba(144, 0, 255, 0.08);
|
||||
--ring: rgba(0, 255, 102, 0.35);
|
||||
--shadow-soft: 0 18px 60px rgba(0, 255, 102, 0.15);
|
||||
--shadow-glass: 0 10px 40px rgba(144, 0, 255, 0.15);
|
||||
--neon-cyan: #00ff66;
|
||||
--neon-purple: #9000ff;
|
||||
--neon-pink: #ff0066;
|
||||
--neon-green: #b3ff00;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-rajdhani), system-ui, sans-serif;
|
||||
--font-mono: var(--font-orbitron), monospace;
|
||||
--color-primary: var(--primary);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted: var(--muted);
|
||||
--color-card: var(--card);
|
||||
--color-glass: var(--glass);
|
||||
--color-neon-cyan: var(--neon-cyan);
|
||||
--color-neon-purple: var(--neon-purple);
|
||||
--color-neon-pink: var(--neon-pink);
|
||||
--color-neon-green: var(--neon-green);
|
||||
--animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-glow: glow 1.5s ease-in-out infinite alternate;
|
||||
--animate-float: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
from { filter: drop-shadow(0 0 2px var(--neon-cyan)); }
|
||||
to { filter: drop-shadow(0 0 10px var(--neon-purple)); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
text-rendering: geometricPrecision;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-image:
|
||||
radial-gradient(1200px 700px at 15% 20%, rgba(144, 0, 255, 0.18) 0%, transparent 55%),
|
||||
radial-gradient(900px 600px at 85% 65%, rgba(0, 255, 102, 0.14) 0%, transparent 55%),
|
||||
radial-gradient(700px 450px at 65% 15%, rgba(255, 0, 102, 0.10) 0%, transparent 50%),
|
||||
radial-gradient(900px 700px at 30% 85%, rgba(179, 255, 0, 0.12) 0%, transparent 55%),
|
||||
linear-gradient(180deg, rgba(0, 255, 102, 0.03), transparent 55%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -5;
|
||||
opacity: 0.8;
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.02) 0px,
|
||||
rgba(255, 255, 255, 0.02) 1px,
|
||||
transparent 2px,
|
||||
transparent 6px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.18) 0px,
|
||||
rgba(0, 0, 0, 0.18) 1px,
|
||||
transparent 2px,
|
||||
transparent 8px
|
||||
);
|
||||
mix-blend-mode: soft-light;
|
||||
}
|
||||
|
||||
/* Glass morphism */
|
||||
.glass {
|
||||
backdrop-filter: blur(10px);
|
||||
background: var(--glass);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
|
||||
.glass:hover {
|
||||
border-color: rgba(0, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
/* Neon text */
|
||||
.neon-text {
|
||||
text-shadow: 0 0 5px currentColor, 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
/* Glowing border */
|
||||
.glow-border {
|
||||
border: 1px solid transparent;
|
||||
background: linear-gradient(var(--card), var(--card)) padding-box,
|
||||
linear-gradient(45deg, var(--neon-cyan), var(--neon-purple)) border-box;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(var(--neon-cyan), var(--neon-purple));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ---- Luxury polish overrides (late-loaded) ---- */
|
||||
a {
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(var(--neon-cyan), var(--neon-purple));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(var(--neon-purple), var(--neon-pink));
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgba(157, 0, 255, 0.55);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.lux-title {
|
||||
background: linear-gradient(90deg, var(--neon-cyan), var(--neon-purple), var(--neon-pink));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.lux-panel {
|
||||
background: var(--glass-strong);
|
||||
box-shadow: var(--shadow-soft);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* ========== SATELLITE PAGE THEMES (distinct feel from main hub) ========== */
|
||||
|
||||
/* Editorial: warm paper, serif, magazine/blog */
|
||||
.theme-editorial {
|
||||
min-height: 100vh;
|
||||
background: #f5f0e8;
|
||||
color: #2c2420;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
.theme-editorial a { color: #6b4e71; }
|
||||
.theme-editorial a:hover { color: #3d2d42; text-decoration: underline; }
|
||||
.theme-editorial .theme-card {
|
||||
background: #fffef9;
|
||||
border: 1px solid #e0d9ce;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.06);
|
||||
}
|
||||
.theme-editorial .theme-accent { color: #6b4e71; }
|
||||
.theme-editorial .theme-header { border-color: #e0d9ce; background: #fffef9; }
|
||||
|
||||
/* Terminal: CRT green/amber on black, monospace */
|
||||
.theme-terminal {
|
||||
min-height: 100vh;
|
||||
background: #0d1117;
|
||||
color: #7ee787;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||
}
|
||||
.theme-terminal::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(0,0,0,0.15) 2px,
|
||||
rgba(0,0,0,0.15) 4px
|
||||
);
|
||||
opacity: 0.4;
|
||||
}
|
||||
.theme-terminal a { color: #f0883e; }
|
||||
.theme-terminal a:hover { color: #ffb86c; }
|
||||
.theme-terminal .theme-card {
|
||||
background: rgba(13, 17, 23, 0.9);
|
||||
border: 1px solid #30363d;
|
||||
box-shadow: 0 0 0 1px rgba(126, 231, 135, 0.1);
|
||||
}
|
||||
.theme-terminal .theme-accent { color: #f0883e; }
|
||||
.theme-terminal .theme-header { border-color: #30363d; background: rgba(13, 17, 23, 0.95); }
|
||||
|
||||
/* Zine: high contrast, bold, punk */
|
||||
.theme-zine {
|
||||
min-height: 100vh;
|
||||
background: #0a0a0a;
|
||||
color: #f5f5f5;
|
||||
font-family: "Impact", "Haettenschweiler", "Arial Narrow Bold", sans-serif;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.theme-zine a { color: #ff6b35; }
|
||||
.theme-zine a:hover { color: #ff9f7a; }
|
||||
.theme-zine .theme-card {
|
||||
background: #141414;
|
||||
border: 3px solid #333;
|
||||
box-shadow: 4px 4px 0 #ff6b35;
|
||||
}
|
||||
.theme-zine .theme-accent { color: #ff6b35; }
|
||||
.theme-zine .theme-header { border-color: #333; background: #0a0a0a; }
|
||||
.theme-zine h1, .theme-zine h2 { font-weight: 900; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
/* Institutional: cold blue-gray, formal gov/corp */
|
||||
.theme-institutional {
|
||||
min-height: 100vh;
|
||||
background: #e8eef4;
|
||||
color: #1a2332;
|
||||
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
.theme-institutional a { color: #1e5a8e; }
|
||||
.theme-institutional a:hover { color: #0d3d66; text-decoration: underline; }
|
||||
.theme-institutional .theme-card {
|
||||
background: #fff;
|
||||
border: 1px solid #c5d0dc;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
}
|
||||
.theme-institutional .theme-accent { color: #1e5a8e; }
|
||||
.theme-institutional .theme-header { border-color: #c5d0dc; background: #fff; }
|
||||
|
||||
/* Nature: earthy green, organic (e.g. Trees Authority) */
|
||||
.theme-nature {
|
||||
min-height: 100vh;
|
||||
background: #1a2f1a;
|
||||
color: #c8e6c9;
|
||||
font-family: "Gill Sans", "Trebuchet MS", sans-serif;
|
||||
}
|
||||
.theme-nature a { color: #81c784; }
|
||||
.theme-nature a:hover { color: #a5d6a7; }
|
||||
.theme-nature .theme-card {
|
||||
background: rgba(27, 55, 27, 0.9);
|
||||
border: 1px solid #2e7d32;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
.theme-nature .theme-accent { color: #81c784; }
|
||||
.theme-nature .theme-header { border-color: #2e7d32; background: rgba(26, 47, 26, 0.98); }
|
||||
282
app/hidden-wiki/page.tsx
Normal file
282
app/hidden-wiki/page.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
type WikiItem = {
|
||||
title: string;
|
||||
note: string;
|
||||
href: string;
|
||||
external: boolean;
|
||||
};
|
||||
|
||||
const ENTRIES: { cat: string; items: WikiItem[] }[] = [
|
||||
{
|
||||
cat: "Reference",
|
||||
items: [
|
||||
{
|
||||
title: "Tor Project (documentation)",
|
||||
note: "Official browser and onion-service documentation (clearnet).",
|
||||
href: "https://www.torproject.org/",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "CyberLux primary hub",
|
||||
note: "Main storefront — verify hostname against signed mirror list.",
|
||||
href: "/",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Darknet Atlas (taxonomy)",
|
||||
note: "Intel-style map of underground verticals — swap in vetted onions after PGP cross-check.",
|
||||
href: "/darknet-atlas",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Account & dashboard",
|
||||
note: "Client-side vault unlock; dashboard aggregates forum, exchange, and barter handles.",
|
||||
href: "/dashboard",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Add funds (Bitcoin)",
|
||||
note: "Verified on-chain deposits credit USD to your signed-in handle.",
|
||||
href: "/account/add-funds",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Account on every hidden mirror",
|
||||
note: "Each .onion hostname has its own storage — register on each host or import a portable bundle.",
|
||||
href: "/account/hidden-services",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Void crawler (internal search)",
|
||||
note: "Ring-local crawler — indexes signed CyberLux routes only; no outbound onions.",
|
||||
href: "/search",
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cat: "Directories & indexes",
|
||||
items: [
|
||||
{
|
||||
title: "Void crawler",
|
||||
note: "Search this deployment’s signed routes.",
|
||||
href: "/search",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Link garden",
|
||||
note: "Curated internal link board.",
|
||||
href: "/links",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Darknet Atlas",
|
||||
note: "Taxonomy with links back into CyberLux surfaces.",
|
||||
href: "/darknet-atlas",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Onion mirror map",
|
||||
note: "Per-host identity + portable account bundle.",
|
||||
href: "/account/hidden-services",
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cat: "Markets & trading",
|
||||
items: [
|
||||
{
|
||||
title: "Market catalog",
|
||||
note: "Primary storefront SKUs.",
|
||||
href: "/market",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Classifieds (exchange)",
|
||||
note: "WTS / WTB listings on this host.",
|
||||
href: "/exchange",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Vendor hall",
|
||||
note: "Seller index and stall applications.",
|
||||
href: "/vendors",
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cat: "Comms & privacy",
|
||||
items: [
|
||||
{
|
||||
title: "Messages (stub)",
|
||||
note: "Local UI shell — no remote mailbox yet.",
|
||||
href: "/messages",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Trust center",
|
||||
note: "Mirror hygiene and safety copy.",
|
||||
href: "/trust",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Support",
|
||||
note: "Contact and routing notes.",
|
||||
href: "/support",
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cat: "Hosting & infrastructure",
|
||||
items: [
|
||||
{
|
||||
title: "Launch / deploy notes",
|
||||
note: "Tor + nginx + verify script — operator entry for the CyberLux onion.",
|
||||
href: "/launch",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Security analysis",
|
||||
note: "In-app security framing.",
|
||||
href: "/security-analysis",
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cat: "Forums & social",
|
||||
items: [
|
||||
{
|
||||
title: "Void Aggregate (forums)",
|
||||
note: "Ringed boards on this build.",
|
||||
href: "/forum",
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
title: "Chatter archive",
|
||||
note: "Hub commentary index.",
|
||||
href: "/chatter",
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cat: "This deployment (internal)",
|
||||
items: [
|
||||
{ title: "Relay grid", note: "Themed entry nodes — same wallet session as hub.", href: "/syndicate", external: false },
|
||||
{ title: "Sign up (local)", note: "Create a handle on this hostname for forum, listings, and dashboard.", href: "/sign-up", external: false },
|
||||
{ title: "Void Aggregate (forums)", note: "Ringed boards — local session data.", href: "/forum", external: false },
|
||||
{ title: "Hub chatter archive", note: "Paged index of threads about the main storefront.", href: "/chatter", external: false },
|
||||
{ title: "Classifieds", note: "WTS / WTB — same deployment.", href: "/exchange", external: false },
|
||||
{ title: "Ash Pit (barter)", note: "Have / want swap board — dark trading floor.", href: "/barter", external: false },
|
||||
{ title: "Link garden", note: "Alternate directory layout.", href: "/links", external: false },
|
||||
{ title: "Checkout", note: "Bitcoin-funded USD balance.", href: "/checkout", external: false },
|
||||
{ title: "Add funds", note: "Deposit flow + txid verify.", href: "/account/add-funds", external: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function HiddenWikiPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#080a0e] text-[#c5d0e0] selection:bg-[#3d5a80] selection:text-white">
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 opacity-[0.12]"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='80' height='80' filter='url(%23n)' opacity='0.15'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none fixed inset-0 z-0 bg-[radial-gradient(ellipse_at_50%_0%,rgba(61,90,128,0.2),transparent_50%)]" />
|
||||
|
||||
<header className="relative z-10 border-b border-[#2a3444] bg-[#0c1018]/95 px-4 py-6 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.4em] text-[#5b8def]/80">mirror — wiki layer</p>
|
||||
<h1 className="mt-2 font-serif text-3xl font-bold tracking-tight text-[#e2e8f0] md:text-4xl">
|
||||
Scrapbook <span className="font-normal italic text-[#94a3b8]">directory</span>
|
||||
</h1>
|
||||
<p className="mt-3 border-l-4 border-[#e07a5f]/80 pl-4 text-sm leading-relaxed text-[#94a8b8]">
|
||||
<strong className="text-[#e2e8f0]">Unvetted links.</strong> Anyone can paste an .onion string — that does not
|
||||
mean it is safe, online, or honest. Verify hostnames against signed posts from sources you already trust.
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-[11px] text-[#6b7c91]">
|
||||
External rows ship with a boilerplate{" "}
|
||||
<code className="rounded bg-[#1a2332] px-1.5 py-0.5 text-[#7eb8ff]">111111111111111111111.onion</code> stub until an
|
||||
operator publishes a signed replacement list.
|
||||
</p>
|
||||
<nav className="mt-5 flex flex-wrap gap-2 font-mono text-xs">
|
||||
<Link
|
||||
href="/"
|
||||
className="border border-[#3d5a80]/60 bg-[#141c2c] px-3 py-1.5 text-[#7eb8ff] hover:border-[#5b8def] hover:bg-[#1a2740]"
|
||||
>
|
||||
Hub
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-up"
|
||||
className="border border-[#2a4a32]/60 bg-[#0f1a14] px-3 py-1.5 text-[#7ef9b9] hover:border-[#3ddf8c]/50"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
<Link
|
||||
href="/account/hidden-services"
|
||||
className="border border-[#2a3444] bg-[#0f141c] px-3 py-1.5 text-[#94a8b8] hover:border-[#3d5a80]/50"
|
||||
>
|
||||
All mirrors
|
||||
</Link>
|
||||
<Link
|
||||
href="/launch"
|
||||
className="border border-[#2a3444] bg-[#0f141c] px-3 py-1.5 text-[#94a8b8] hover:border-[#3d5a80]/50"
|
||||
>
|
||||
Launch
|
||||
</Link>
|
||||
<Link
|
||||
href="/syndicate"
|
||||
className="border border-[#2a3444] bg-[#0f141c] px-3 py-1.5 text-[#94a8b8] hover:border-[#3d5a80]/50"
|
||||
>
|
||||
Relay grid
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto max-w-3xl px-4 pb-20 pt-10">
|
||||
{ENTRIES.map((section) => (
|
||||
<section key={section.cat} className="mb-12">
|
||||
<h2 className="font-mono text-xs font-bold uppercase tracking-widest text-[#e07a5f]/90">{section.cat}</h2>
|
||||
<ul className="mt-4 space-y-4 border-t border-[#2a3444] pt-4">
|
||||
{section.items.map((item) => (
|
||||
<li key={`${section.cat}-${item.title}`} className="border-b border-[#2a3444]/80 pb-4 last:border-0">
|
||||
{item.external ? (
|
||||
<a
|
||||
href={item.href}
|
||||
className="font-mono text-sm font-semibold text-[#7eb8ff] underline decoration-[#5b8def]/40 underline-offset-4 hover:decoration-[#7eb8ff]"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{item.title}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="font-mono text-sm font-semibold text-[#7eb8ff] underline decoration-[#5b8def]/40 underline-offset-4 hover:decoration-[#7eb8ff]"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
)}
|
||||
<p className="mt-1 font-serif text-sm text-[#94a8b8]">{item.note}</p>
|
||||
{item.external ? (
|
||||
<p className="mt-1.5 font-mono text-xs break-all text-[#6b7c91]">{item.href}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
197
app/hosting/page.tsx
Normal file
197
app/hosting/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
const PLANS = [
|
||||
{
|
||||
id: "spectre",
|
||||
name: "Spectre",
|
||||
tagline: "Ghost-tier starter slice",
|
||||
price: 50,
|
||||
unit: "VOID / mo",
|
||||
specs: { cpu: "1 vCPU", ram: "512 MB", disk: "5 GB SSD", bw: "100 GB", onions: 1 },
|
||||
features: ["Tor v3 hidden service", "Managed torrc", "SSH access", "Basic DDoS mitigation", "99.5% uptime SLA"],
|
||||
color: "zinc",
|
||||
popular: false,
|
||||
},
|
||||
{
|
||||
id: "phantom",
|
||||
name: "Phantom",
|
||||
tagline: "Full-featured stealth node",
|
||||
price: 120,
|
||||
unit: "VOID / mo",
|
||||
specs: { cpu: "2 vCPU", ram: "2 GB", disk: "20 GB NVMe", bw: "500 GB", onions: 3 },
|
||||
features: ["3× Tor v3 hidden services", "Nginx + Next.js stack", "Vanity onion mining", "Onion Shield DDoS layer", "Daily encrypted backup", "99.9% uptime SLA"],
|
||||
color: "purple",
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: "wraith",
|
||||
name: "Wraith",
|
||||
tagline: "Maximum stealth, maximum power",
|
||||
price: 300,
|
||||
unit: "VOID / mo",
|
||||
specs: { cpu: "4 vCPU", ram: "8 GB", disk: "80 GB NVMe", bw: "2 TB", onions: 10 },
|
||||
features: ["10× Tor v3 hidden services", "Custom stack support", "Priority vanity mining", "Full Onion Shield stack", "Hourly encrypted backup", "DDoS + probe mitigation", "99.99% uptime SLA", "Dedicated support channel"],
|
||||
color: "cyan",
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
const FAQS = [
|
||||
{ q: "How do I pay?", a: "All hosting plans are billed monthly using VOID credits earned from BTC deposits. First-month payment is charged on sign-up." },
|
||||
{ q: "Will my .onion address persist?", a: "Yes. We back up your hs_ed25519_secret_key nightly. Your onion address is tied to your key, not the machine." },
|
||||
{ q: "What OS do you provision?", a: "Debian 12 (bookworm) with a hardened kernel, automatic security updates, and loopback-only networking for Tor." },
|
||||
{ q: "Can I run custom software?", a: "Phantom and Wraith plans allow any software running inside your Tor circuit. We do not proxy clearnet traffic." },
|
||||
{ q: "What happens to my data if I cancel?", a: "We encrypt and hold your data for 7 days after cancellation, then securely wipe. Backups are provided on request." },
|
||||
];
|
||||
|
||||
export default function HostingPage() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-[#0a0a0a] pt-24 pb-20">
|
||||
{/* Hero */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-16 text-center">
|
||||
<p className="mb-3 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">
|
||||
darkhost · tor-native infrastructure
|
||||
</p>
|
||||
<h1 className="font-orbitron text-5xl font-bold md:text-6xl">
|
||||
DARK<span className="text-neon-cyan">HOST</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-5 max-w-2xl text-lg text-foreground/60">
|
||||
The dark web's hosting provider. Deploy hidden services on hardened, loopback-only infrastructure managed by CyberLux. Your keys, your onion, your rules.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap justify-center gap-4 text-sm text-foreground/50">
|
||||
<span className="flex items-center gap-2"><span className="text-neon-cyan">✓</span> Loopback-only — zero clearnet exposure</span>
|
||||
<span className="flex items-center gap-2"><span className="text-neon-cyan">✓</span> Tor v3 only — no legacy v2 services</span>
|
||||
<span className="flex items-center gap-2"><span className="text-neon-cyan">✓</span> Paid in BTC via VOID credits</span>
|
||||
<span className="flex items-center gap-2"><span className="text-neon-cyan">✓</span> Managed Tor/nginx/systemd stack</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plans */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-20">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{PLANS.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
onClick={() => setSelected(plan.id === selected ? null : plan.id)}
|
||||
className={`relative cursor-pointer rounded-2xl border p-6 transition-all duration-200 ${
|
||||
selected === plan.id
|
||||
? "border-neon-cyan/60 bg-neon-cyan/5 shadow-lg shadow-neon-cyan/10"
|
||||
: plan.popular
|
||||
? "border-neon-purple/50 bg-neon-purple/5"
|
||||
: "border-zinc-700/40 bg-black/40 hover:border-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{plan.popular && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-neon-purple px-4 py-0.5 text-[10px] font-bold tracking-widest text-background">
|
||||
POPULAR
|
||||
</div>
|
||||
)}
|
||||
<h2 className="font-orbitron text-2xl font-bold">{plan.name}</h2>
|
||||
<p className="mt-1 text-xs text-foreground/50">{plan.tagline}</p>
|
||||
<p className="mt-5 font-mono text-3xl font-bold text-neon-purple">
|
||||
✦ {plan.price}
|
||||
<span className="ml-2 text-sm text-foreground/50">{plan.unit}</span>
|
||||
</p>
|
||||
|
||||
{/* Specs */}
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 text-xs">
|
||||
{Object.entries(plan.specs).map(([k, v]) => (
|
||||
<div key={k} className="rounded-lg bg-white/5 px-3 py-2">
|
||||
<p className="text-foreground/40 uppercase tracking-wider text-[9px]">{k}</p>
|
||||
<p className="font-bold text-foreground">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="mt-5 space-y-1.5">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-xs text-foreground/70">
|
||||
<span className="mt-0.5 text-neon-cyan">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href="/account/add-funds"
|
||||
className={`block w-full rounded-lg py-2.5 text-center text-sm font-bold transition ${
|
||||
plan.popular
|
||||
? "bg-gradient-to-r from-neon-cyan to-neon-purple text-background hover:opacity-90"
|
||||
: "border border-zinc-600 bg-white/5 text-foreground hover:border-zinc-400"
|
||||
}`}
|
||||
>
|
||||
Reserve {plan.name} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-20">
|
||||
<h2 className="font-orbitron mb-8 text-2xl font-bold text-center">How It Works</h2>
|
||||
<div className="grid gap-6 sm:grid-cols-4">
|
||||
{[
|
||||
{ step: "01", title: "Deposit BTC", desc: "Fund your account with BTC. Credits appear after 1 confirmation." },
|
||||
{ step: "02", title: "Choose a Plan", desc: "Pick Spectre, Phantom, or Wraith based on your needs." },
|
||||
{ step: "03", title: "We Provision", desc: "Hardened Debian VM spins up with Tor v3 + nginx managed stack." },
|
||||
{ step: "04", title: "You Deploy", desc: "SSH in, drop your app, we handle the Tor side. Your keys, your onion." },
|
||||
].map((s) => (
|
||||
<div key={s.step} className="rounded-xl border border-zinc-800 bg-black/40 p-5">
|
||||
<p className="font-orbitron text-3xl font-black text-neon-cyan/20">{s.step}</p>
|
||||
<p className="mt-2 font-bold text-foreground">{s.title}</p>
|
||||
<p className="mt-1 text-sm text-foreground/55">{s.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="container mx-auto max-w-3xl px-4 mb-16">
|
||||
<h2 className="font-orbitron mb-6 text-2xl font-bold text-center">FAQ</h2>
|
||||
<div className="space-y-2">
|
||||
{FAQS.map((faq, i) => (
|
||||
<div key={i} className="rounded-xl border border-zinc-800 bg-black/40 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === i ? null : i)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 text-sm font-semibold text-left"
|
||||
>
|
||||
{faq.q}
|
||||
<span className="text-foreground/40">{openFaq === i ? "−" : "+"}</span>
|
||||
</button>
|
||||
{openFaq === i && (
|
||||
<div className="px-5 pb-4 text-sm text-foreground/60">{faq.a}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="container mx-auto max-w-2xl px-4 text-center">
|
||||
<div className="rounded-2xl border border-neon-cyan/20 bg-neon-cyan/5 p-10">
|
||||
<h2 className="font-orbitron text-2xl font-bold mb-3">Ready to go dark?</h2>
|
||||
<p className="text-foreground/60 mb-6">Deposit BTC, earn VOID credits, and spin up your hidden service in minutes.</p>
|
||||
<Link
|
||||
href="/account/add-funds"
|
||||
className="inline-block rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-8 py-3 font-bold text-background hover:opacity-90"
|
||||
>
|
||||
Fund Account → Deploy
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
app/inner-circle/page.tsx
Normal file
229
app/inner-circle/page.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { useWallet } from "@/contexts/WalletContext";
|
||||
|
||||
const MEMBERSHIP_TIERS = [
|
||||
{ name: "Associate", minLux: 0, description: "Access to the channel digest and ring updates." },
|
||||
{ name: "Operative", minLux: 500, description: "Priority listing visibility and vault drop previews." },
|
||||
{ name: "Handler", minLux: 2000, description: "Early market access, barter priority lane, vendor fast-track." },
|
||||
{ name: "Principal", minLux: 8000, description: "All tiers plus operator-level forum ring keys and direct relay contact." },
|
||||
];
|
||||
|
||||
function getTier(lux: number) {
|
||||
let result = MEMBERSHIP_TIERS[0]!;
|
||||
for (const t of MEMBERSHIP_TIERS) {
|
||||
if (lux >= t.minLux) result = t;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const DIGEST = [
|
||||
{ tag: "MARKET", text: "New entropy dongle batch listed — vendor @relay_op, verified PGP.", href: "/market" },
|
||||
{ tag: "FORUM", text: "Thread on OPSEC hygiene for multi-site handles is trending in Ring IV.", href: "/forum" },
|
||||
{ tag: "DROPS", text: "Next scheduled drop in ~18h. Add /drops to your watchlist.", href: "/drops" },
|
||||
{ tag: "EXCHANGE", text: "WTB listing for XMR-to-credit bridge services posted 4h ago.", href: "/exchange" },
|
||||
{ tag: "VAULT", text: "Your receipts and earned keys live in the Vault — check after checkout.", href: "/vault" },
|
||||
];
|
||||
|
||||
export default function InnerCirclePage() {
|
||||
const { user, hydrated } = useAccount();
|
||||
const { luxCredits } = useWallet();
|
||||
const tier = getTier(luxCredits);
|
||||
const [tab, setTab] = useState<"digest" | "tiers" | "guide">("digest");
|
||||
|
||||
if (!hydrated) {
|
||||
return (
|
||||
<ThemedLayout theme="brutalist" title="The Inner Circle">
|
||||
<div className="mx-auto max-w-md pt-20 text-center font-mono text-sm text-foreground/50">
|
||||
Loading…
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<ThemedLayout theme="brutalist" title="The Inner Circle">
|
||||
<div className="mx-auto max-w-md pt-20">
|
||||
<div className="border-[8px] border-[#e5e5e5] bg-[#161616] p-12 shadow-[16px_16px_0_rgba(0,0,0,0.8)] text-[#f0f0f0]">
|
||||
<h2 className="mb-4 text-4xl font-black uppercase italic">Members Only</h2>
|
||||
<p className="mb-8 text-sm text-[#aaa]">
|
||||
The Inner Circle is gated by your CyberLux handle. Sign in and earn LUX credits through purchases and
|
||||
forum activity to unlock higher tiers.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
href="/sign-in?next=/inner-circle"
|
||||
className="block w-full bg-[#f0f0f0] py-4 text-center font-black uppercase text-black transition-colors hover:bg-[#7cfc9a]"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-up?next=/inner-circle"
|
||||
className="block w-full border-4 border-[#e5e5e5] py-4 text-center font-black uppercase hover:border-[#7cfc9a]"
|
||||
>
|
||||
Create Handle
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-10 border-t border-white/10 pt-6">
|
||||
<p className="text-[10px] uppercase text-[#666]">Tier access based on LUX balance — no paid gate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="brutalist" title="The Inner Circle">
|
||||
<div className="mx-auto max-w-3xl pt-12 pb-20 px-4">
|
||||
<div className="border-[8px] border-[#e5e5e5] bg-[#161616] shadow-[16px_16px_0_rgba(0,0,0,0.8)] text-[#f0f0f0]">
|
||||
{/* Header */}
|
||||
<div className="border-b-4 border-[#e5e5e5] bg-[#111] px-8 py-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-3xl font-black uppercase italic">Welcome, @{user.username}</h2>
|
||||
<p className="mt-1 text-sm text-[#aaa]">
|
||||
Tier:{" "}
|
||||
<span className="font-black text-[#7cfc9a]">{tier.name}</span>
|
||||
{" · "}
|
||||
<span className="text-[#7cfc9a]">{luxCredits.toLocaleString()} LUX</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-[10px] uppercase text-[#666]">
|
||||
<div className="text-2xl font-black text-[#7cfc9a]">{tier.name}</div>
|
||||
<div>{tier.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tier progress */}
|
||||
<div className="border-b-4 border-[#3a3a3a] px-8 py-4">
|
||||
<div className="flex gap-2">
|
||||
{MEMBERSHIP_TIERS.map((t, i) => {
|
||||
const reached = luxCredits >= t.minLux;
|
||||
const active = t.name === tier.name;
|
||||
return (
|
||||
<div
|
||||
key={t.name}
|
||||
className={`flex-1 border-2 py-2 text-center text-[10px] font-black uppercase transition-colors ${
|
||||
active
|
||||
? "border-[#7cfc9a] bg-[#7cfc9a]/15 text-[#7cfc9a]"
|
||||
: reached
|
||||
? "border-[#666] text-[#aaa]"
|
||||
: "border-[#333] text-[#555]"
|
||||
}`}
|
||||
>
|
||||
<div>{t.name}</div>
|
||||
<div className="font-normal">{t.minLux.toLocaleString()} LUX</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b-4 border-[#3a3a3a]">
|
||||
{(["digest", "tiers", "guide"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-3 text-[11px] font-black uppercase transition-colors ${
|
||||
tab === t ? "bg-[#7cfc9a]/15 text-[#7cfc9a]" : "text-[#888] hover:text-[#ccc]"
|
||||
}`}
|
||||
>
|
||||
{t === "digest" ? "Signal Digest" : t === "tiers" ? "Tier Benefits" : "Operations Guide"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{tab === "digest" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] uppercase text-[#666]">Current hub signals · real-time activity</p>
|
||||
{DIGEST.map((d) => (
|
||||
<Link
|
||||
key={d.tag}
|
||||
href={d.href}
|
||||
className="flex items-start gap-4 border-2 border-[#333] bg-[#0d0d0d] p-4 transition-all hover:border-[#7cfc9a]/40"
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 rounded bg-[#7cfc9a]/15 px-2 py-0.5 text-[10px] font-black uppercase text-[#7cfc9a]">
|
||||
{d.tag}
|
||||
</span>
|
||||
<p className="text-sm text-[#ddd]">{d.text}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "tiers" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-[11px] uppercase text-[#666]">Earn LUX via purchases and activity</p>
|
||||
{MEMBERSHIP_TIERS.map((t) => {
|
||||
const reached = luxCredits >= t.minLux;
|
||||
return (
|
||||
<div
|
||||
key={t.name}
|
||||
className={`border-2 p-6 ${reached ? "border-[#7cfc9a]/40 bg-[#0f1a0f]" : "border-[#333] bg-[#0d0d0d] opacity-60"}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xl font-black">{t.name}</div>
|
||||
<div className="text-sm font-bold text-[#7cfc9a]">{t.minLux.toLocaleString()} LUX</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-[#aaa]">{t.description}</p>
|
||||
{!reached && (
|
||||
<p className="mt-2 text-xs text-[#666]">
|
||||
Need {(t.minLux - luxCredits).toLocaleString()} more LUX
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "guide" && (
|
||||
<div className="space-y-6 text-sm text-[#ccc]">
|
||||
<p className="text-[11px] uppercase text-[#666]">Operational guidelines</p>
|
||||
<div>
|
||||
<h4 className="mb-2 font-black uppercase text-[#f0f0f0]">Earning LUX</h4>
|
||||
<p className="text-[#aaa] leading-relaxed">
|
||||
LUX credits are earned on every purchase at checkout. Spend at the Mixer (coin-blending theatre),
|
||||
or hold to maintain tier status. LUX is stored per-handle in your browser — export your account
|
||||
bundle to carry it across devices.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-black uppercase text-[#f0f0f0]">Adding Funds</h4>
|
||||
<p className="text-[#aaa] leading-relaxed">
|
||||
Fund your account via Bitcoin at{" "}
|
||||
<Link href="/account/add-funds" className="text-[#7cfc9a] underline">
|
||||
/account/add-funds
|
||||
</Link>
|
||||
. After one confirmation, paste the txid — USD credit is applied to your handle at current spot.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-black uppercase text-[#f0f0f0]">OPSEC basics</h4>
|
||||
<p className="text-[#aaa] leading-relaxed">
|
||||
Use Tor Browser on .onion deployments. Export your account bundle from{" "}
|
||||
<Link href="/dashboard" className="text-[#7cfc9a] underline">
|
||||
/dashboard
|
||||
</Link>{" "}
|
||||
before clearing site data. Verify mirror PGP each session — phishing clones exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
267
app/labs/page.tsx
Normal file
267
app/labs/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
const LAB_TOOLS = [
|
||||
{
|
||||
id: "hash-station",
|
||||
name: "Hash Station",
|
||||
desc: "Compute MD5, SHA-1, SHA-256, SHA-512, BLAKE2b, and Keccak-256 hashes in-browser. Zero upload.",
|
||||
icon: "🔢",
|
||||
free: true,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "base-converter",
|
||||
name: "Base Converter",
|
||||
desc: "Convert between hex, base64, base32, binary, UTF-8, and URL encoding. Handles BIP38 and WIF keys.",
|
||||
icon: "⇄",
|
||||
free: true,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "entropy-gauge",
|
||||
name: "Entropy Gauge",
|
||||
desc: "Measure Shannon entropy of any text or file. Identify patterns, weak passphrases, and compressed data.",
|
||||
icon: "📊",
|
||||
free: true,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "regex-forge",
|
||||
name: "Regex Forge",
|
||||
desc: "Build and test regex patterns against log files, addresses, and hashes. Syntax: PCRE2 compatible.",
|
||||
icon: "🔧",
|
||||
free: false,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "tls-inspector",
|
||||
name: "TLS Inspector",
|
||||
desc: "Analyse TLS certificates: parse PEM/DER, check expiry, verify chain, extract SANs and public keys.",
|
||||
icon: "🔒",
|
||||
free: false,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "steganography-lab",
|
||||
name: "Stego Lab",
|
||||
desc: "Hide and extract messages in PNG images using LSB steganography. All processing client-side.",
|
||||
icon: "🖼️",
|
||||
free: false,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "btc-address-lab",
|
||||
name: "BTC Address Lab",
|
||||
desc: "Derive Bitcoin addresses from private keys (WIF/hex), validate addresses, inspect scripts, and decode transactions.",
|
||||
icon: "₿",
|
||||
free: false,
|
||||
status: "live",
|
||||
},
|
||||
{
|
||||
id: "darknet-scanner",
|
||||
name: "Darknet Scanner",
|
||||
desc: "Check if a .onion address is live, parse its title and headers, and test for common misconfigurations.",
|
||||
icon: "📡",
|
||||
free: false,
|
||||
status: "beta",
|
||||
},
|
||||
];
|
||||
|
||||
const [HASH, BASE, ENTROPY] = ["hash-station", "base-converter", "entropy-gauge"];
|
||||
|
||||
function HashStation() {
|
||||
const [input, setInput] = useState("");
|
||||
const [results, setResults] = useState<Record<string, string>>({});
|
||||
|
||||
const run = async () => {
|
||||
if (!input) return;
|
||||
const enc = new TextEncoder().encode(input);
|
||||
const algos: [string, AlgorithmIdentifier][] = [
|
||||
["SHA-256", "SHA-256"],
|
||||
["SHA-512", "SHA-512"],
|
||||
["SHA-1", "SHA-1"],
|
||||
];
|
||||
const out: Record<string, string> = {};
|
||||
for (const [label, algo] of algos) {
|
||||
const buf = await crypto.subtle.digest(algo, enc);
|
||||
out[label] = Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
setResults(out);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-700/40 bg-black/50 p-5">
|
||||
<textarea
|
||||
className="w-full rounded-lg bg-zinc-900 border border-zinc-700 p-3 text-sm font-mono text-foreground placeholder-foreground/30 resize-none"
|
||||
rows={4}
|
||||
placeholder="Enter text to hash…"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
onClick={run}
|
||||
className="mt-3 rounded-lg bg-neon-cyan/20 border border-neon-cyan/30 px-5 py-2 text-sm font-bold text-neon-cyan hover:bg-neon-cyan/30"
|
||||
>
|
||||
Hash
|
||||
</button>
|
||||
{Object.entries(results).length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
{Object.entries(results).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<p className="text-[10px] uppercase tracking-widest text-foreground/40 mb-1">{k}</p>
|
||||
<p className="font-mono text-xs text-neon-cyan break-all">{v}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseConverter() {
|
||||
const [input, setInput] = useState("");
|
||||
const [output, setOutput] = useState("");
|
||||
const [from, setFrom] = useState("text");
|
||||
const [to, setTo] = useState("hex");
|
||||
|
||||
const convert = () => {
|
||||
try {
|
||||
let bytes: Uint8Array;
|
||||
if (from === "text") bytes = new TextEncoder().encode(input);
|
||||
else if (from === "hex") bytes = new Uint8Array(input.match(/.{1,2}/g)!.map((h) => parseInt(h, 16)));
|
||||
else if (from === "base64") bytes = Uint8Array.from(atob(input), (c) => c.charCodeAt(0));
|
||||
else bytes = new TextEncoder().encode(input);
|
||||
|
||||
if (to === "hex") setOutput(Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""));
|
||||
else if (to === "base64") setOutput(btoa(String.fromCharCode(...bytes)));
|
||||
else if (to === "text") setOutput(new TextDecoder().decode(bytes));
|
||||
else setOutput(Array.from(bytes).map((b) => b.toString(2).padStart(8, "0")).join(" "));
|
||||
} catch {
|
||||
setOutput("Conversion error — check input format");
|
||||
}
|
||||
};
|
||||
|
||||
const modes = ["text", "hex", "base64", "binary"];
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-700/40 bg-black/50 p-5 space-y-3">
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
{(["from", "to"] as const).map((side) => (
|
||||
<div key={side} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-foreground/50">{side.toUpperCase()}:</span>
|
||||
{modes.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => side === "from" ? setFrom(m) : setTo(m)}
|
||||
className={`rounded px-2 py-1 border ${(side === "from" ? from : to) === m ? "border-neon-cyan/40 bg-neon-cyan/10 text-neon-cyan" : "border-zinc-700 text-foreground/50 hover:border-zinc-500"}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<textarea className="w-full rounded-lg bg-zinc-900 border border-zinc-700 p-3 text-sm font-mono text-foreground placeholder-foreground/30 resize-none" rows={3} placeholder="Input…" value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
<button onClick={convert} className="rounded-lg bg-neon-cyan/20 border border-neon-cyan/30 px-5 py-2 text-sm font-bold text-neon-cyan hover:bg-neon-cyan/30">Convert</button>
|
||||
{output && <p className="font-mono text-xs text-neon-cyan break-all bg-zinc-900 rounded p-3">{output}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntropyGauge() {
|
||||
const [input, setInput] = useState("");
|
||||
const score = (() => {
|
||||
if (!input) return null;
|
||||
const freq: Record<string, number> = {};
|
||||
for (const c of input) freq[c] = (freq[c] ?? 0) + 1;
|
||||
const n = input.length;
|
||||
return -Object.values(freq).reduce((s, f) => { const p = f / n; return s + p * Math.log2(p); }, 0);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-700/40 bg-black/50 p-5 space-y-3">
|
||||
<textarea className="w-full rounded-lg bg-zinc-900 border border-zinc-700 p-3 text-sm font-mono text-foreground placeholder-foreground/30 resize-none" rows={4} placeholder="Paste text to measure entropy…" value={input} onChange={(e) => setInput(e.target.value)} />
|
||||
{score !== null && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-foreground/50">Shannon entropy</span>
|
||||
<span className={`font-mono font-bold text-sm ${score > 4.5 ? "text-neon-green" : score > 3 ? "text-yellow-400" : "text-red-400"}`}>{score.toFixed(3)} bits/char</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-zinc-800">
|
||||
<div className={`h-2 rounded-full transition-all ${score > 4.5 ? "bg-neon-green" : score > 3 ? "bg-yellow-400" : "bg-red-400"}`} style={{ width: `${Math.min(100, (score / 6) * 100)}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-foreground/40">{score > 4.5 ? "High entropy — good randomness" : score > 3 ? "Moderate — some patterns detected" : "Low entropy — likely weak or patterned"}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LabsPage() {
|
||||
const [active, setActive] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-[#0a0a0a] pt-24 pb-20">
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-12">
|
||||
<p className="mb-2 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">void labs · browser-native security tools</p>
|
||||
<h1 className="font-orbitron text-4xl font-bold md:text-5xl">VOID <span className="text-neon-cyan">LABS</span></h1>
|
||||
<p className="mt-3 max-w-xl text-foreground/60">Privacy and security micro-tools that run entirely in your browser. No uploads. No logs. Free and premium tiers.</p>
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-10">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{LAB_TOOLS.map((t) => (
|
||||
<div key={t.id} onClick={() => setActive(active === t.id ? null : t.id)}
|
||||
className={`cursor-pointer rounded-xl border p-4 transition ${active === t.id ? "border-neon-cyan/50 bg-neon-cyan/5" : "border-zinc-700/40 bg-black/40 hover:border-zinc-500"}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<span className="text-2xl">{t.icon}</span>
|
||||
<div className="flex gap-1">
|
||||
{t.free ? <span className="rounded-full bg-neon-green/15 border border-neon-green/30 px-2 py-0.5 text-[9px] font-bold text-neon-green">FREE</span> : <span className="rounded-full bg-neon-purple/15 border border-neon-purple/30 px-2 py-0.5 text-[9px] font-bold text-neon-purple">VOID</span>}
|
||||
{t.status === "beta" && <span className="rounded-full bg-yellow-500/15 border border-yellow-500/30 px-2 py-0.5 text-[9px] font-bold text-yellow-400">BETA</span>}
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-semibold text-sm">{t.name}</p>
|
||||
<p className="mt-1 text-xs text-foreground/50 leading-snug">{t.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Inline tools */}
|
||||
{active && (
|
||||
<section className="container mx-auto max-w-3xl px-4 mb-10">
|
||||
<h2 className="font-orbitron text-lg font-bold mb-4">
|
||||
{LAB_TOOLS.find((t) => t.id === active)?.name}
|
||||
</h2>
|
||||
{active === HASH && <HashStation />}
|
||||
{active === BASE && <BaseConverter />}
|
||||
{active === ENTROPY && <EntropyGauge />}
|
||||
{active !== HASH && active !== BASE && active !== ENTROPY && (
|
||||
<div className="rounded-xl border border-zinc-700/40 bg-black/40 p-8 text-center">
|
||||
<p className="text-foreground/50 mb-4">This tool requires VOID credits to access.</p>
|
||||
<Link href="/tools" className="rounded-full bg-neon-purple/20 border border-neon-purple/40 px-6 py-2.5 text-sm font-bold text-neon-purple hover:bg-neon-purple/30">
|
||||
Unlock in VOID Tools →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="container mx-auto max-w-2xl px-4 mt-10 text-center">
|
||||
<div className="rounded-2xl border border-neon-cyan/20 bg-neon-cyan/5 p-8">
|
||||
<h2 className="font-orbitron text-xl font-bold mb-3">Premium labs unlock</h2>
|
||||
<p className="text-sm text-foreground/60 mb-5">Premium tools are included with the VOID Tools subscription. Deposit BTC to get VOID credits.</p>
|
||||
<Link href="/account/add-funds" className="inline-block rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-7 py-2.5 text-sm font-bold text-background hover:opacity-90">
|
||||
+ Get VOID Credits
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
app/launch/page.tsx
Normal file
63
app/launch/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import ParticleBackground from "@/components/ParticleBackground";
|
||||
|
||||
export default function LaunchPage() {
|
||||
return (
|
||||
<>
|
||||
<ParticleBackground />
|
||||
<Navbar />
|
||||
<main className="relative z-10 mx-auto max-w-3xl px-4 pb-32 pt-28">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/80">deployment</p>
|
||||
<h1 className="mt-2 font-orbitron text-4xl font-black text-white md:text-5xl">Launch on the dark web</h1>
|
||||
<p className="mt-4 text-foreground/75">
|
||||
Ship this stack as one Tor v3 hidden service: nginx on loopback, Next.js on{" "}
|
||||
<code className="rounded bg-white/10 px-1.5 py-0.5 text-neon-green">127.0.0.1:3000</code>, every vertical as a path.
|
||||
Repo ships generators and systemd hooks — you bring the box and opsec.
|
||||
</p>
|
||||
|
||||
<section className="mt-12 space-y-6 rounded-2xl border border-white/10 bg-black/40 p-8 backdrop-blur">
|
||||
<h2 className="font-orbitron text-lg font-bold text-neon-purple">1. Build & wire Tor + nginx</h2>
|
||||
<pre className="overflow-x-auto rounded-xl bg-black/60 p-4 text-xs leading-relaxed text-neon-cyan/90">
|
||||
{`cd ~/cyberlux
|
||||
./start.sh
|
||||
# or prepare only, then systemd:
|
||||
# CYBERLUX_PREPARE_ONLY=1 ./start.sh
|
||||
# sudo CYBERLUX_USER=$USER bash scripts/install-systemd.sh`}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4 rounded-2xl border border-white/10 bg-black/40 p-8 backdrop-blur">
|
||||
<h2 className="font-orbitron text-lg font-bold text-neon-purple">2. Run the app</h2>
|
||||
<p className="text-sm text-foreground/70">
|
||||
Foreground: <code className="text-neon-green">npm run start:onion</code> · Boot:{" "}
|
||||
<code className="text-neon-green">sudo systemctl start cyberlux.service</code>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 space-y-4 rounded-2xl border border-white/10 bg-black/40 p-8 backdrop-blur">
|
||||
<h2 className="font-orbitron text-lg font-bold text-neon-purple">3. Verify onion & loopback</h2>
|
||||
<pre className="overflow-x-auto rounded-xl bg-black/60 p-4 text-xs text-neon-cyan/90">
|
||||
{`npm run health:stack
|
||||
npm run onions:list # CyberLux .onion URL
|
||||
sudo bash scripts/list-onion-urls.sh # if hostname files need root
|
||||
npm run onions:status # URLs + loopback checks`}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
<p className="mt-10 text-sm text-foreground/55">
|
||||
Full reference: <code className="text-foreground/80">DEPLOY.md</code> in the repo root (Torrc, key backup, firewall).
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-wrap gap-4">
|
||||
<Link href="/" className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-3 text-sm font-bold text-black">
|
||||
← Hub
|
||||
</Link>
|
||||
<Link href="/account/hidden-services" className="glass rounded-full border border-white/20 px-6 py-3 text-sm font-semibold">
|
||||
Hidden services & mirrors
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
app/layout.tsx
Normal file
38
app/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Orbitron, Rajdhani } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import RootLayoutClient from "./root-layout-client";
|
||||
|
||||
const orbitron = Orbitron({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-orbitron",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const rajdhani = Rajdhani({
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
variable: "--font-rajdhani",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "EldritchWeave",
|
||||
template: "%s · EldritchWeave",
|
||||
},
|
||||
description: "Curated apothecary, grimoires, covenants, and shadow rails.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className={`dark ${orbitron.variable} ${rajdhani.variable}`} suppressHydrationWarning>
|
||||
<body
|
||||
className={`min-h-screen bg-[#0a0a0a] text-foreground antialiased ${orbitron.variable} ${rajdhani.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<RootLayoutClient>{children}</RootLayoutClient>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
213
app/links/page.tsx
Normal file
213
app/links/page.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
import { LINK_GARDEN_INTERNAL } from "@/lib/cyberluxSitemap";
|
||||
|
||||
type Resource = {
|
||||
name: string;
|
||||
description: string;
|
||||
href: string;
|
||||
category: string;
|
||||
trust: "internal" | "clearnet" | "onion-required";
|
||||
icon: string;
|
||||
external: boolean;
|
||||
};
|
||||
|
||||
const RESOURCES: Resource[] = [
|
||||
{
|
||||
name: "Tor Project",
|
||||
description: "Official documentation for the Tor anonymity network and Tor Browser. Start here for anything Tor-related.",
|
||||
href: "https://www.torproject.org/",
|
||||
category: "Infrastructure",
|
||||
trust: "clearnet",
|
||||
icon: "🧅",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "EFF — Surveillance Self-Defense",
|
||||
description: "Electronic Frontier Foundation's guide to protecting yourself from digital surveillance.",
|
||||
href: "https://ssd.eff.org/",
|
||||
category: "OPSEC",
|
||||
trust: "clearnet",
|
||||
icon: "🛡️",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "Tails OS",
|
||||
description: "Privacy-focused amnesic operating system that leaves no trace. Runs from a USB drive.",
|
||||
href: "https://tails.boum.org/",
|
||||
category: "Infrastructure",
|
||||
trust: "clearnet",
|
||||
icon: "💾",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "Privacy Guides",
|
||||
description: "Community-maintained recommendations for privacy software, services, and tools across every category.",
|
||||
href: "https://www.privacyguides.org/",
|
||||
category: "Reference",
|
||||
trust: "clearnet",
|
||||
icon: "📚",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "SecureDrop",
|
||||
description: "Open-source whistleblower submission platform used by major news organizations.",
|
||||
href: "https://securedrop.org/",
|
||||
category: "Whistleblowing",
|
||||
trust: "clearnet",
|
||||
icon: "📮",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "Monero (XMR)",
|
||||
description: "Privacy-focused cryptocurrency with ring signatures, stealth addresses, and confidential transactions.",
|
||||
href: "https://www.getmonero.org/",
|
||||
category: "Finance",
|
||||
trust: "clearnet",
|
||||
icon: "⏣",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "OpenPGP standard",
|
||||
description: "The spec behind PGP-encrypted communications. Essential reading for key hygiene and signing.",
|
||||
href: "https://www.openpgp.org/",
|
||||
category: "Cryptography",
|
||||
trust: "clearnet",
|
||||
icon: "🔑",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
name: "Have I Been Pwned",
|
||||
description: "Check if your email or phone was in a data breach. Free lookup, no account needed.",
|
||||
href: "https://haveibeenpwned.com/",
|
||||
category: "Security",
|
||||
trust: "clearnet",
|
||||
icon: "🔍",
|
||||
external: true,
|
||||
},
|
||||
];
|
||||
|
||||
const TRUST_LABELS: Record<Resource["trust"], { label: string; color: string }> = {
|
||||
internal: { label: "Internal", color: "text-neon-green" },
|
||||
clearnet: { label: "Clearnet", color: "text-neon-cyan" },
|
||||
"onion-required": { label: "Tor only", color: "text-amber-400" },
|
||||
};
|
||||
|
||||
export default function LinksPage() {
|
||||
const [cat, setCat] = useState<string>("All");
|
||||
const cats = ["All", ...Array.from(new Set(RESOURCES.map((r) => r.category)))];
|
||||
const visible = cat === "All" ? RESOURCES : RESOURCES.filter((r) => r.category === cat);
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="institutional" siteTitle="LINK GARDEN" siteSubtitle="Curated clearnet + internal resources">
|
||||
<section className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="text-center">
|
||||
<h1 className="font-orbitron text-5xl font-bold md:text-6xl">
|
||||
LINK <span className="theme-accent">GARDEN</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-3xl text-lg opacity-80">
|
||||
Curated privacy and security resources. External links go to real clearnet sites. Internal links stay on
|
||||
this deployment. Every .onion row in the Atlas requires Tor Browser — these are clearnet-accessible
|
||||
references.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 py-8">
|
||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-4">
|
||||
<h2 className="font-orbitron text-3xl font-bold">EXTERNAL RESOURCES</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cats.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setCat(c)}
|
||||
className={`rounded-full border px-4 py-2 text-xs font-bold uppercase transition-all ${
|
||||
cat === c
|
||||
? "border-neon-cyan bg-neon-cyan/15 text-neon-cyan"
|
||||
: "border-white/15 text-foreground/60 hover:border-white/30"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3">
|
||||
{visible.map((r) => {
|
||||
const trust = TRUST_LABELS[r.trust];
|
||||
return (
|
||||
<div
|
||||
key={r.name}
|
||||
className="theme-card group relative overflow-hidden rounded-2xl border border-white/10 p-6 transition-all hover:border-neon-cyan/30"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-2">
|
||||
<span className="text-3xl">{r.icon}</span>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className={`text-[10px] font-bold uppercase ${trust.color}`}>{trust.label}</span>
|
||||
<span className="rounded-full bg-white/5 px-2 py-0.5 text-[10px] text-foreground/50">
|
||||
{r.category}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-bold">{r.name}</h3>
|
||||
<p className="mb-4 text-sm leading-relaxed opacity-70">{r.description}</p>
|
||||
<a
|
||||
href={r.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm font-bold theme-accent hover:underline"
|
||||
>
|
||||
Visit ↗
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 py-16">
|
||||
<h2 className="mb-8 font-orbitron text-3xl font-bold">INTERNAL ROUTES</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LINK_GARDEN_INTERNAL.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="theme-card group flex items-start gap-4 rounded-2xl border border-white/10 p-5 transition-all hover:border-neon-cyan/30 hover:bg-white/[0.03]"
|
||||
>
|
||||
<span className="mt-0.5 text-2xl">{item.icon}</span>
|
||||
<div>
|
||||
<div className="font-bold">{item.name}</div>
|
||||
<p className="mt-1 text-sm opacity-60">{item.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 pb-20">
|
||||
<div className="theme-card rounded-2xl border border-white/10 p-8 text-sm">
|
||||
<h3 className="mb-4 font-bold text-neon-cyan">Using Tor Browser</h3>
|
||||
<p className="leading-relaxed opacity-70">
|
||||
The external resources above are clearnet sites — accessible in any browser. For accessing .onion
|
||||
services (including this deployment when running behind Tor), you must use{" "}
|
||||
<a
|
||||
href="https://www.torproject.org/download/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-neon-cyan underline"
|
||||
>
|
||||
Tor Browser
|
||||
</a>
|
||||
. On mobile, use <strong>Onion Browser</strong> (iOS) or <strong>Tor Browser for Android</strong>. Safari
|
||||
and Chrome cannot resolve .onion hostnames regardless of settings.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
119
app/market/analytics/page.tsx
Normal file
119
app/market/analytics/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { SHOP_PRODUCTS, shopCategoryCounts, type ShopCurrency } from "@/lib/shopCatalog";
|
||||
|
||||
function currencySymbol(c: ShopCurrency): string {
|
||||
if (c === "BTC") return "₿";
|
||||
if (c === "ETH") return "Ξ";
|
||||
if (c === "XMR") return "⏣";
|
||||
return "$";
|
||||
}
|
||||
|
||||
export default function MarketAnalyticsPage() {
|
||||
const { byCategory, byCurrency, avgPriceUsdish, limitedCount } = useMemo(() => {
|
||||
const cats = shopCategoryCounts();
|
||||
const cur: Record<ShopCurrency, number> = { BTC: 0, ETH: 0, USD: 0, XMR: 0 };
|
||||
let usdSum = 0;
|
||||
let n = 0;
|
||||
let lim = 0;
|
||||
for (const p of SHOP_PRODUCTS) {
|
||||
cur[p.currency]++;
|
||||
if (p.currency === "USD") {
|
||||
usdSum += p.price;
|
||||
n++;
|
||||
} else if (p.currency === "BTC") {
|
||||
usdSum += p.price * 65_000;
|
||||
n++;
|
||||
} else if (p.currency === "ETH") {
|
||||
usdSum += p.price * 3_500;
|
||||
n++;
|
||||
} else {
|
||||
usdSum += p.price * 165;
|
||||
n++;
|
||||
}
|
||||
if (p.limited) lim++;
|
||||
}
|
||||
const maxCat = Math.max(1, ...cats.map((c) => c.count));
|
||||
const maxCur = Math.max(1, ...Object.values(cur));
|
||||
return {
|
||||
byCategory: cats.map((c) => ({ ...c, pct: Math.round((c.count / maxCat) * 100) })),
|
||||
byCurrency: (Object.entries(cur) as [ShopCurrency, number][]).map(([k, v]) => ({
|
||||
k,
|
||||
v,
|
||||
pct: Math.round((v / maxCur) * 100),
|
||||
})),
|
||||
avgPriceUsdish: n ? usdSum / n : 0,
|
||||
limitedCount: lim,
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-10 font-mono text-[#c8ffd8]">
|
||||
<header>
|
||||
<p className="text-[10px] uppercase tracking-[0.45em] text-[#00ff41]/45">Layer 2 · floor telemetry</p>
|
||||
<h1 className="mt-2 text-2xl font-black tracking-tight text-[#7af598] md:text-3xl">Analytics</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#5a8f6a]">
|
||||
Derived from the live catalog in <code className="text-[#7af598]">shopCatalog</code> — not a third-party charting plugin. Bars are CSS; numbers update when SKUs change. Use this view to explain category heat and currency mix on vendor calls or mirror status pages.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-3">
|
||||
<div className="rounded border border-[#00ff41]/25 bg-[#050a08]/90 p-5">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[#00ff41]/50">SKUs</p>
|
||||
<p className="mt-1 text-3xl font-black text-[#b4ffcc]">{SHOP_PRODUCTS.length}</p>
|
||||
</div>
|
||||
<div className="rounded border border-[#00ff41]/25 bg-[#050a08]/90 p-5">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[#00ff41]/50">Limited drops</p>
|
||||
<p className="mt-1 text-3xl font-black text-[#ff9a9a]">{limitedCount}</p>
|
||||
</div>
|
||||
<div className="rounded border border-[#00ff41]/25 bg-[#050a08]/90 p-5">
|
||||
<p className="text-[10px] uppercase tracking-widest text-[#00ff41]/50">Avg notional (rough USD)</p>
|
||||
<p className="mt-1 text-3xl font-black text-[#7af598]">
|
||||
${avgPriceUsdish.toFixed(0)}
|
||||
</p>
|
||||
<p className="mt-2 text-[9px] text-[#4a6b55]">BTC/ETH/XMR converted with static fiction rates for display only.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/20 p-6">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/70">Category mix</h2>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{byCategory.map((c) => (
|
||||
<li key={c.name} className="flex items-center gap-3 text-xs">
|
||||
<span className="w-8 shrink-0 text-lg">{c.icon}</span>
|
||||
<span className="w-36 shrink-0 font-bold text-[#b4ffcc]">{c.name}</span>
|
||||
<div className="h-2 min-w-0 flex-1 overflow-hidden rounded bg-[#0a120d]">
|
||||
<div className="h-full bg-gradient-to-r from-[#00ff41]/20 to-[#7af598]" style={{ width: `${c.pct}%` }} />
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right text-[#6a9b7a]">{c.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<aside className="rounded border border-dashed border-[#00ff41]/25 p-4 text-[10px] leading-relaxed text-[#4a6b55]">
|
||||
<strong className="text-[#5a8f6a]">Telemetry vocabulary:</strong> “heat” is relative SKU density per category, “mix” is currency tag share, “notional” is a
|
||||
fiction USD bridge using static BTC/ETH/XMR anchors — useful for comparing stalls, not for pricing legal tender. All terms index the{" "}
|
||||
<strong className="text-[#6a9b7a]">Void crawler</strong> when mirrored on search-enabled onions.
|
||||
</aside>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/20 p-6">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/70">Currency mix</h2>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{byCurrency.map(({ k, v, pct }) => (
|
||||
<li key={k} className="flex items-center gap-3 text-xs">
|
||||
<span className="w-10 shrink-0 font-bold text-[#9dffc4]">
|
||||
{currencySymbol(k)} {k}
|
||||
</span>
|
||||
<div className="h-2 min-w-0 flex-1 overflow-hidden rounded bg-[#0a120d]">
|
||||
<div className="h-full bg-[#00ff41]/35" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right text-[#6a9b7a]">{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
app/market/intel/page.tsx
Normal file
97
app/market/intel/page.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MarketIntelPage() {
|
||||
return (
|
||||
<div className="space-y-10 font-mono text-[#c8ffd8]">
|
||||
<header>
|
||||
<p className="text-[10px] uppercase tracking-[0.45em] text-[#00ff41]/45">Layer 3 · vetting</p>
|
||||
<h1 className="mt-2 text-2xl font-black tracking-tight text-[#7af598] md:text-3xl">Buyer intelligence</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#5a8f6a]">
|
||||
A deep market needs a deep threat model. This page is the in-universe briefing buyers read before large orders: how to read stall telemetry, when to walk away, and how CyberLux surfaces risk without turning the UI into nagware.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/20 p-6">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/70">Stall signals (what the UI already shows)</h2>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-left text-xs text-[#7ab896]">
|
||||
<thead>
|
||||
<tr className="border-b border-[#00ff41]/20 text-[10px] uppercase tracking-wider text-[#00ff41]/50">
|
||||
<th className="py-2 pr-4">Signal</th>
|
||||
<th className="py-2 pr-4">Healthy</th>
|
||||
<th className="py-2">Caution</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#00ff41]/10">
|
||||
<tr>
|
||||
<td className="py-3 font-bold text-[#b4ffcc]">Rating trajectory</td>
|
||||
<td>Stable ★ over 90d window</td>
|
||||
<td>Sudden jump after mass low-value sales</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 font-bold text-[#b4ffcc]">Review velocity</td>
|
||||
<td>Matches order volume in analytics band</td>
|
||||
<td>Spike of one-liners, no verified purchases</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 font-bold text-[#b4ffcc]">PGP history</td>
|
||||
<td>Chained rotations with signed notices</td>
|
||||
<td>Fresh key + aggressive discounting</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 font-bold text-[#b4ffcc]">Category fit</td>
|
||||
<td>Listings match stated specialty</td>
|
||||
<td>Random SKU sprawl outside bond tier</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded border border-dashed border-[#ff6b6b]/35 bg-[#1a0808]/40 p-5">
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-[#ff9a9a]">Hard stops</h3>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-4 text-xs text-[#c9a0a0]">
|
||||
<li>Vendor asks to finalize off-platform “to save fees.”</li>
|
||||
<li>Mirror URL not present in your signed bundle.</li>
|
||||
<li>Checkout address differs from last verified fingerprint without a signed rotation.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded border border-[#00ff41]/20 p-5">
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/70">Soft signals</h3>
|
||||
<ul className="mt-3 list-disc space-y-2 pl-4 text-xs text-[#6a9b7a]">
|
||||
<li>New stall with perfect photos — request timestamped proof.</li>
|
||||
<li>SKU copies another listing verbatim — possible scraper.</li>
|
||||
<li>Shipping region mismatch vs vendor claimed locale.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/15 p-6 text-xs leading-relaxed text-[#6a9b7a]">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/60">Narrative environment</h2>
|
||||
<p className="mt-3">
|
||||
Buyers should assume <strong className="text-[#b4ffcc]">Sybil stalls</strong>, <strong className="text-[#b4ffcc]">liquidity ghosts</strong>, and{" "}
|
||||
<strong className="text-[#b4ffcc]">mirror drift</strong> until a signed bundle says otherwise. The floor encourages{" "}
|
||||
<strong className="text-[#b4ffcc]">compartmentalized handles</strong>, <strong className="text-[#b4ffcc]">burner sessions</strong>, and{" "}
|
||||
<strong className="text-[#b4ffcc]">jurisdiction-agnostic shipping fiction</strong> so Tor latency and packet jitter do not get mistaken for vendor
|
||||
incompetence. When in doubt, cross-read <strong className="text-[#b4ffcc]">forum rings</strong> for stall drama before large commits.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-[10px] text-[#4a6b55]">
|
||||
Cross-links:{" "}
|
||||
<Link href="/market/analytics" className="text-[#7af598] hover:underline">
|
||||
Floor analytics
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/search" className="text-[#7af598] hover:underline">
|
||||
Crawler
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/forum" className="text-[#7af598] hover:underline">
|
||||
Forum rings
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/market/layout.tsx
Normal file
16
app/market/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Suspense } from "react";
|
||||
import MarketSiteChrome from "@/components/market/MarketSiteChrome";
|
||||
|
||||
export default function MarketLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<MarketSiteChrome>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="py-16 text-center font-mono text-sm text-[#00ff41]/45">Loading catalog…</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</MarketSiteChrome>
|
||||
);
|
||||
}
|
||||
102
app/market/operations/page.tsx
Normal file
102
app/market/operations/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MarketOperationsPage() {
|
||||
return (
|
||||
<div className="space-y-10 font-mono text-[#c8ffd8]">
|
||||
<header>
|
||||
<p className="text-[10px] uppercase tracking-[0.45em] text-[#00ff41]/45">Layer 2 · fulfillment stack</p>
|
||||
<h1 className="mt-2 text-2xl font-black tracking-tight text-[#7af598] md:text-3xl">Operations & pipeline</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#5a8f6a]">
|
||||
The market is not a static directory. Every SKU routes through a staged pipeline: intake, bond, listing QA, buyer messaging, ship window, and dispute arbitration. Below is how CyberLux models that stack in software — so mirrors and onions stay coherent even when traffic splits across forty-three hidden services.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/25 bg-[#050a08]/90 p-6 md:p-8">
|
||||
<h2 className="border-b border-[#00ff41]/15 pb-2 text-sm font-black uppercase tracking-widest text-[#9dffc4]">
|
||||
Order state machine
|
||||
</h2>
|
||||
<ol className="mt-6 space-y-4 text-sm leading-relaxed text-[#7ab896]">
|
||||
<li>
|
||||
<strong className="text-[#b4ffcc]">Draft</strong> — cart lines held client-side; no vendor notification until checkout commits a signed bundle.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#b4ffcc]">Committed</strong> — checkout posts intent; escrow layer (see{" "}
|
||||
<Link href="/market/trust" className="text-[#7af598] underline hover:text-white">
|
||||
Trust
|
||||
</Link>
|
||||
) opens a cooling window for key verification.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#b4ffcc]">Vendor ack</strong> — stall owner confirms stock and ship SLA; late ack downgrades stall priority in search ranking.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#b4ffcc]">In transit</strong> — tracking tokens are opaque hashes; plaintext carriers never touch the hub.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[#b4ffcc]">Settled / disputed</strong> — auto-release after timeout unless a ticket opens a moderator queue tied to the same order id across all onions.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 md:grid-cols-2">
|
||||
<div className="rounded border border-[#00ff41]/20 p-6">
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/70">SLA tiers (in-world)</h3>
|
||||
<ul className="mt-4 space-y-3 text-xs leading-relaxed text-[#6a9b7a]">
|
||||
<li>
|
||||
<span className="font-bold text-[#9dffc4]">Standard:</span> vendor ack within 48h simulated clock; ship within 120h.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-bold text-[#9dffc4]">Express bond:</span> higher vendor bond unlocks 12h ack and pinned placement in category rails.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-bold text-[#9dffc4]">Cold chain / sensitive:</span> extra QA step before listing goes live; edits re-trigger QA.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="rounded border border-[#00ff41]/20 p-6">
|
||||
<h3 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/70">Dispute ladder</h3>
|
||||
<ol className="mt-4 list-decimal space-y-2 pl-4 text-xs leading-relaxed text-[#6a9b7a]">
|
||||
<li>Buyer ↔ vendor thread (PGP-signed).</li>
|
||||
<li>Floor mediator — binds to listing category expertise.</li>
|
||||
<li>Market council — multi-sig release or partial refund.</li>
|
||||
<li>Permanent ban + bond forfeit for pattern fraud.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-dashed border-[#00ff41]/30 p-6 text-xs leading-relaxed text-[#4a6b55]">
|
||||
<strong className="text-[#6a9b7a]">Why this page exists:</strong> dedicated Tor onions for <code className="text-[#7af598]">/market</code> should feel like a full vertical, not a shallow iframe. The pipeline text mirrors how cart, checkout, and support routes are wired in the Next app so operators can explain the same story on any mirror.
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/20 bg-[#030806]/80 p-6 md:p-8">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/65">Glossary · ops vocabulary</h2>
|
||||
<dl className="mt-4 grid gap-4 text-xs text-[#6a9b7a] md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="font-bold text-[#9dffc4]">Signed bundle</dt>
|
||||
<dd className="mt-1 leading-relaxed">
|
||||
Mirror list + hub fingerprint + timestamp; clients verify before trusting checkout addresses or vendor rotations across rendezvous points.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-bold text-[#9dffc4]">Opaque hash</dt>
|
||||
<dd className="mt-1 leading-relaxed">
|
||||
Tracking token that maps to carrier events only inside vendor tooling — plaintext courier names never cross the Next boundary.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-bold text-[#9dffc4]">Cooling window</dt>
|
||||
<dd className="mt-1 leading-relaxed">
|
||||
Post-commit interval where buyers validate PGP continuity and multisig cosigners can abort without burning reputation.
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="font-bold text-[#9dffc4]">Category rail</dt>
|
||||
<dd className="mt-1 leading-relaxed">
|
||||
Faceted slice of the catalog used for pinned placement; express-bond stalls can lease rail time without polluting unrelated SKUs.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
app/market/page.tsx
Normal file
299
app/market/page.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import {
|
||||
SHOP_PRODUCT_COUNT,
|
||||
SHOP_PRODUCTS,
|
||||
resolveProductImage,
|
||||
shopCategoryCounts,
|
||||
type ShopCurrency,
|
||||
type ShopProduct,
|
||||
} from "@/lib/shopCatalog";
|
||||
import { estimateUsdForCryptoAmount } from "@/lib/shopFx";
|
||||
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
|
||||
import { SHOP_SELLER_COUNT, getSellerById } from "@/lib/shopSellers";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const SYM: Record<ShopCurrency, string> = {
|
||||
BTC: "₿",
|
||||
ETH: "Ξ",
|
||||
USD: "$",
|
||||
XMR: "⏣",
|
||||
};
|
||||
|
||||
type SortKey = "relevance" | "price-asc" | "price-desc" | "name";
|
||||
|
||||
function MarketContent() {
|
||||
const sp = useSearchParams();
|
||||
const router = useRouter();
|
||||
const initialCat = sp.get("category") ?? "";
|
||||
const [q, setQ] = useState("");
|
||||
const [category, setCategory] = useState(initialCat);
|
||||
const [sort, setSort] = useState<SortKey>("relevance");
|
||||
const [currencyFilter, setCurrencyFilter] = useState<ShopCurrency | "">("");
|
||||
const [btcUsd, setBtcUsd] = useState<number | null>(null);
|
||||
|
||||
const facet = useMemo(() => shopCategoryCounts(), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const j = (await res.json()) as { bitcoin?: { usd?: number } };
|
||||
if (!cancelled && j.bitcoin?.usd && Number.isFinite(j.bitcoin.usd)) setBtcUsd(j.bitcoin.usd);
|
||||
} catch {
|
||||
if (!cancelled) setBtcUsd(96000);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const spotBtc = btcUsd ?? 96000;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const ql = q.trim().toLowerCase();
|
||||
const tokens = ql.split(/\s+/).filter(Boolean);
|
||||
return SHOP_PRODUCTS.filter((p) => {
|
||||
if (category && p.category !== category) return false;
|
||||
if (currencyFilter && p.currency !== currencyFilter) return false;
|
||||
if (tokens.length === 0) return true;
|
||||
const seller = getSellerById(p.sellerId);
|
||||
const sellerHay = seller ? `${seller.handle} ${seller.bio} ${seller.specialty}` : "";
|
||||
const hay = `${p.name} ${p.description} ${p.category} ${p.id} ${sellerHay}`.toLowerCase();
|
||||
return tokens.every((t) => hay.includes(t));
|
||||
});
|
||||
}, [q, category, currencyFilter]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const list = [...filtered];
|
||||
const unitUsd = (p: ShopProduct) => estimateUsdForCryptoAmount(p.price, p.currency, spotBtc);
|
||||
switch (sort) {
|
||||
case "price-asc":
|
||||
return list.sort((a, b) => unitUsd(a) - unitUsd(b));
|
||||
case "price-desc":
|
||||
return list.sort((a, b) => unitUsd(b) - unitUsd(a));
|
||||
case "name":
|
||||
return list.sort((a, b) => a.name.localeCompare(b.name));
|
||||
default:
|
||||
return list;
|
||||
}
|
||||
}, [filtered, sort, spotBtc]);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-[#00ff41]">
|
||||
<section className="mb-6 border-b border-[#00ff41]/20 pb-4">
|
||||
<p className="text-[10px] uppercase tracking-[0.4em] text-[#00ff41]/50">Layer 1 · live floor</p>
|
||||
<p className="mt-2 max-w-3xl text-sm text-[#00ff41]/65">
|
||||
<strong className="text-[#7af598]">{SHOP_PRODUCT_COUNT}</strong> SKUs ·{" "}
|
||||
<Link href="/vendors" className="font-bold text-[#9dffc4] underline hover:text-white">
|
||||
{SHOP_SELLER_COUNT} vendor stalls
|
||||
</Link>
|
||||
. Every SKU has a detail page with qty, cart, checkout, or instant USD balance payment. Spot:{" "}
|
||||
<span className="text-[#7af598]">${spotBtc.toLocaleString(undefined, { maximumFractionDigits: 0 })}/BTC</span>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="flex-1">
|
||||
<label className="text-[10px] uppercase tracking-widest text-[#00ff41]/50">Search catalog</label>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="title, description, vendor…"
|
||||
className="mt-1 w-full max-w-xl border-2 border-[#00ff41]/30 bg-black/80 px-4 py-2.5 text-sm text-[#c8ffd8] placeholder:text-[#00ff41]/25 focus:border-[#00ff41] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-[#00ff41]/50">
|
||||
<span>
|
||||
Showing <span className="text-[#7af598]">{sorted.length}</span> / {SHOP_PRODUCT_COUNT}
|
||||
</span>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase">Sort</span>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortKey)}
|
||||
className="border border-[#00ff41]/35 bg-black px-2 py-1 text-[11px] text-[#c8ffd8]"
|
||||
>
|
||||
<option value="relevance">Default</option>
|
||||
<option value="price-asc">Price ↑ (USD est.)</option>
|
||||
<option value="price-desc">Price ↓ (USD est.)</option>
|
||||
<option value="name">Name A–Z</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<span className="text-[10px] uppercase">Currency</span>
|
||||
<select
|
||||
value={currencyFilter}
|
||||
onChange={(e) => setCurrencyFilter((e.target.value || "") as ShopCurrency | "")}
|
||||
className="border border-[#00ff41]/35 bg-black px-2 py-1 text-[11px] text-[#c8ffd8]"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="BTC">BTC</option>
|
||||
<option value="ETH">ETH</option>
|
||||
<option value="USD">USD</option>
|
||||
<option value="XMR">XMR</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCategory("")}
|
||||
className={`px-3 py-1.5 text-xs uppercase ${category === "" ? "bg-[#00ff41] text-black" : "border border-[#00ff41]/35 hover:bg-[#00ff41]/10"}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{facet.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => setCategory(c.name)}
|
||||
className={`px-3 py-1.5 text-xs ${category === c.name ? "bg-[#00ff41] text-black" : "border border-[#00ff41]/35 hover:bg-[#00ff41]/10"}`}
|
||||
>
|
||||
<span className="mr-1">{c.icon}</span>
|
||||
{c.name} ({c.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{sorted.map((p) => (
|
||||
<MarketProductCard key={p.id} product={p} spotBtc={spotBtc} onBuyNow={() => router.push("/checkout")} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sorted.length === 0 ? (
|
||||
<p className="mt-16 border border-dashed border-[#00ff41]/20 py-12 text-center text-sm text-[#00ff41]/50">
|
||||
No SKUs match — widen search or pick another category.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MarketPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="font-mono text-sm text-[#00ff41]/50">Loading market…</div>}>
|
||||
<MarketContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function MarketProductCard({
|
||||
product: p,
|
||||
spotBtc,
|
||||
onBuyNow,
|
||||
}: {
|
||||
product: ShopProduct;
|
||||
spotBtc: number;
|
||||
onBuyNow: () => void;
|
||||
}) {
|
||||
const src = resolveProductImage(p);
|
||||
const local = src.startsWith("/");
|
||||
const v = getSellerById(p.sellerId);
|
||||
const metrics = getProductSocialMetrics(p.id);
|
||||
const { addToCart, hydrated } = useCart();
|
||||
const [flash, setFlash] = useState(false);
|
||||
const unitUsd = estimateUsdForCryptoAmount(p.price, p.currency, spotBtc);
|
||||
|
||||
const add = () => {
|
||||
addToCart(p, 1);
|
||||
setFlash(true);
|
||||
window.setTimeout(() => setFlash(false), 1400);
|
||||
};
|
||||
|
||||
const buyNow = () => {
|
||||
addToCart(p, 1);
|
||||
onBuyNow();
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="flex h-full flex-col border border-[#00ff41]/20 bg-[#0d0d0d] transition-all hover:border-[#00ff41]/55">
|
||||
<Link href={`/market/product/${encodeURIComponent(p.id)}`} className="relative block aspect-square w-full bg-black">
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 640px) 100vw, (max-width: 1280px) 50vw, 25vw"
|
||||
unoptimized={local}
|
||||
/>
|
||||
{p.limited ? (
|
||||
<span className="absolute left-2 top-2 bg-[#ff00aa] px-2 py-0.5 text-[10px] font-bold uppercase text-black">Limited</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#00ff41]/45">{p.category}</p>
|
||||
<Link href={`/market/product/${encodeURIComponent(p.id)}`}>
|
||||
<h2 className="mt-1 text-base font-bold leading-snug text-[#d8ffe8] hover:text-[#7af598]">{p.name}</h2>
|
||||
</Link>
|
||||
<div className="mt-2 text-[#9dffc4]">
|
||||
<ProductSocialBlock metrics={metrics} compact />
|
||||
</div>
|
||||
{v ? (
|
||||
<p className="mt-2 text-[10px] text-[#00ff41]/55">
|
||||
<span className="text-[#00ff41]/40">Seller</span>{" "}
|
||||
<Link href={`/vendor/${v.slug}`} className="text-[#7af598] hover:text-white hover:underline">
|
||||
{v.handle}
|
||||
</Link>
|
||||
<span className="text-[#00ff41]/35"> · stall ★{v.rating.toFixed(2)}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-relaxed text-[#00ff41]/70">{p.description}</p>
|
||||
<div className="mt-3 flex items-baseline justify-between gap-2 border-t border-[#00ff41]/15 pt-3">
|
||||
<div>
|
||||
<span className="text-lg font-bold text-[#7af598]">
|
||||
{SYM[p.currency]}
|
||||
{formatPrice(p.price, p.currency)} <span className="text-xs font-normal text-[#00ff41]/50">{p.currency}</span>
|
||||
</span>
|
||||
<p className="font-mono text-[10px] text-[#5a8f6a]">≈ ${unitUsd.toFixed(2)} USD</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/market/product/${encodeURIComponent(p.id)}`}
|
||||
className="shrink-0 text-[10px] font-bold uppercase text-[#7af598] underline hover:text-white"
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hydrated}
|
||||
onClick={add}
|
||||
className="flex-1 border border-[#00ff41] bg-[#00ff41] px-2 py-1.5 text-[10px] font-bold uppercase text-black hover:bg-[#7af598] disabled:opacity-50"
|
||||
>
|
||||
{flash ? "Added" : "Add"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hydrated}
|
||||
onClick={buyNow}
|
||||
className="flex-1 border border-[#00ff41]/40 px-2 py-1.5 text-[10px] font-bold uppercase text-[#7af598] hover:bg-[#00ff41]/10 disabled:opacity-50"
|
||||
>
|
||||
Buy now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
220
app/market/product/[id]/page.tsx
Normal file
220
app/market/product/[id]/page.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ProductSocialBlock } from "@/components/shop/ProductSocialBlock";
|
||||
import PayWithUsdBalanceButton from "@/components/PayWithUsdBalanceButton";
|
||||
import { useCart } from "@/contexts/CartContext";
|
||||
import {
|
||||
getShopProductById,
|
||||
resolveProductImage,
|
||||
type ShopCurrency,
|
||||
} from "@/lib/shopCatalog";
|
||||
import { estimateUsdForCryptoAmount } from "@/lib/shopFx";
|
||||
import { getProductSocialMetrics } from "@/lib/shopProductSocial";
|
||||
import { getSellerById } from "@/lib/shopSellers";
|
||||
|
||||
const SYM: Record<ShopCurrency, string> = {
|
||||
BTC: "₿",
|
||||
ETH: "Ξ",
|
||||
USD: "$",
|
||||
XMR: "⏣",
|
||||
};
|
||||
|
||||
function formatPrice(price: number, currency: ShopCurrency): string {
|
||||
if (currency === "BTC") return price.toFixed(6);
|
||||
if (currency === "ETH") return price.toFixed(4);
|
||||
if (currency === "XMR") return price.toFixed(3);
|
||||
return price.toFixed(2);
|
||||
}
|
||||
|
||||
export default function MarketProductPage() {
|
||||
const params = useParams();
|
||||
const id = typeof params?.id === "string" ? params.id : "";
|
||||
const router = useRouter();
|
||||
const product = id ? getShopProductById(id) : undefined;
|
||||
const [btcUsd, setBtcUsd] = useState<number | null>(null);
|
||||
const [qty, setQty] = useState(1);
|
||||
const [addedFlash, setAddedFlash] = useState(false);
|
||||
const { addToCart, hydrated } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const j = (await res.json()) as { bitcoin?: { usd?: number } };
|
||||
if (!cancelled && j.bitcoin?.usd && Number.isFinite(j.bitcoin.usd)) setBtcUsd(j.bitcoin.usd);
|
||||
} catch {
|
||||
if (!cancelled) setBtcUsd(96000);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const spot = btcUsd ?? 96000;
|
||||
|
||||
const lineUsd = useMemo(() => {
|
||||
if (!product) return 0;
|
||||
const unit = estimateUsdForCryptoAmount(product.price, product.currency, spot);
|
||||
return Math.round(unit * qty * 100) / 100;
|
||||
}, [product, qty, spot]);
|
||||
|
||||
const buyNow = useCallback(() => {
|
||||
if (!product || !hydrated) return;
|
||||
addToCart(product, qty);
|
||||
router.push("/checkout");
|
||||
}, [product, qty, addToCart, hydrated, router]);
|
||||
|
||||
const addOnly = useCallback(() => {
|
||||
if (!product || !hydrated) return;
|
||||
addToCart(product, qty);
|
||||
setAddedFlash(true);
|
||||
window.setTimeout(() => setAddedFlash(false), 1200);
|
||||
}, [product, qty, addToCart, hydrated]);
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<div className="border border-dashed border-[#00ff41]/25 py-20 text-center font-mono text-sm text-[#00ff41]/55">
|
||||
<p>SKU not found.</p>
|
||||
<Link href="/market" className="mt-4 inline-block text-[#7af598] underline">
|
||||
← Back to catalog
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src = resolveProductImage(product);
|
||||
const local = src.startsWith("/");
|
||||
const v = getSellerById(product.sellerId);
|
||||
const metrics = getProductSocialMetrics(product.id);
|
||||
const unitUsd = estimateUsdForCryptoAmount(product.price, product.currency, spot);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-[#c8ffd8]">
|
||||
<nav className="mb-6 text-[10px] uppercase tracking-wider text-[#5a8f6a]">
|
||||
<Link href="/market" className="text-[#7af598] hover:underline">
|
||||
← Catalog
|
||||
</Link>
|
||||
<span className="mx-2 text-[#00ff41]/25">/</span>
|
||||
<span className="text-[#00ff41]/55">{product.category}</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-2">
|
||||
<div className="relative aspect-square w-full overflow-hidden border border-[#00ff41]/20 bg-black">
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw,50vw"
|
||||
unoptimized={local}
|
||||
/>
|
||||
{product.limited ? (
|
||||
<span className="absolute left-2 top-2 bg-[#ff00aa] px-2 py-0.5 text-[10px] font-bold uppercase text-black">
|
||||
Limited
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-[#00ff41]/45">{product.category}</p>
|
||||
<h1 className="mt-2 font-mono text-2xl font-bold leading-tight text-[#e8fff0] md:text-3xl">{product.name}</h1>
|
||||
<div className="mt-3 text-[#9dffc4]">
|
||||
<ProductSocialBlock metrics={metrics} compact={false} />
|
||||
</div>
|
||||
{v ? (
|
||||
<p className="mt-3 text-[11px] text-[#00ff41]/55">
|
||||
Seller{" "}
|
||||
<Link href={`/vendor/${v.slug}`} className="text-[#7af598] hover:underline">
|
||||
{v.handle}
|
||||
</Link>
|
||||
<span className="text-[#00ff41]/35"> · ★{v.rating.toFixed(2)}</span>
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="mt-4 text-sm leading-relaxed text-[#00ff41]/75">{product.description}</p>
|
||||
|
||||
<div className="mt-6 border border-[#00ff41]/20 bg-[#0a120d] p-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase text-[#5a8f6a]">List price</p>
|
||||
<p className="text-2xl font-bold text-[#7af598]">
|
||||
{SYM[product.currency]}
|
||||
{formatPrice(product.price, product.currency)}{" "}
|
||||
<span className="text-sm font-normal text-[#00ff41]/45">{product.currency}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-[#5a8f6a]">
|
||||
≈ ${unitUsd.toFixed(2)} USD / unit @ spot
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] uppercase text-[#5a8f6a]">Line total (est.)</p>
|
||||
<p className="text-xl font-bold text-[#b4ffcc]">${lineUsd.toFixed(2)} USD</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<span className="text-[10px] uppercase text-[#5a8f6a]">Qty</span>
|
||||
<button
|
||||
type="button"
|
||||
className="border border-[#00ff41]/35 px-2 py-1 text-sm hover:bg-[#00ff41]/10"
|
||||
onClick={() => setQty(Math.max(1, qty - 1))}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center font-mono">{qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="border border-[#00ff41]/35 px-2 py-1 text-sm hover:bg-[#00ff41]/10"
|
||||
onClick={() => setQty(Math.min(99, qty + 1))}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 border-t border-[#00ff41]/15 pt-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hydrated}
|
||||
onClick={addOnly}
|
||||
className="flex-1 border border-[#00ff41]/40 bg-transparent px-4 py-3 text-xs font-bold uppercase text-[#7af598] hover:bg-[#00ff41]/10 disabled:opacity-50"
|
||||
>
|
||||
{addedFlash ? "Added ✓" : "Add to cart"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hydrated}
|
||||
onClick={buyNow}
|
||||
className="flex-1 border border-[#00ff41] bg-[#00ff41] px-4 py-3 text-xs font-bold uppercase text-black hover:bg-[#7af598] disabled:opacity-50"
|
||||
>
|
||||
Buy now → checkout
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-[#5a8f6a]">
|
||||
Checkout converts every line to USD using live BTC spot, then charges your verified Bitcoin-funded USD balance.
|
||||
</p>
|
||||
<div className="rounded border border-[#00ff41]/20 bg-black/40 p-3">
|
||||
<p className="mb-2 text-[10px] font-bold uppercase text-[#7af598]">Express · pay from USD balance</p>
|
||||
<PayWithUsdBalanceButton
|
||||
amountUsd={lineUsd}
|
||||
title={`Market: ${product.name}`}
|
||||
description={`Qty ${qty} · ${product.id}`}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
app/market/trust/page.tsx
Normal file
80
app/market/trust/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MarketTrustPage() {
|
||||
return (
|
||||
<div className="space-y-10 font-mono text-[#c8ffd8]">
|
||||
<header>
|
||||
<p className="text-[10px] uppercase tracking-[0.45em] text-[#00ff41]/45">Layer 3 · settlement</p>
|
||||
<h1 className="mt-2 text-2xl font-black tracking-tight text-[#7af598] md:text-3xl">Trust, escrow & keys</h1>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-[#5a8f6a]">
|
||||
Buyers should never wonder which “plugin” holds their funds. CyberLux treats trust as a layered contract: cryptographic identity first, timed release second, human arbitration last. Everything below is diegetic documentation for the storefront — it aligns with how sessions, carts, and signed mirror lists behave in code.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xs font-black uppercase tracking-[0.35em] text-[#00ff41]/55">Four trust layers</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
n: "L1",
|
||||
t: "Vendor bond",
|
||||
d: "New stalls post collateral denominated in XMR/BTC fiction. Bond size gates category access (e.g. hardware vs digital).",
|
||||
},
|
||||
{
|
||||
n: "L2",
|
||||
t: "PGP continuity",
|
||||
d: "Every listing shows a vendor fingerprint; checkout warns on mismatch. Rotations require signed announcements chained to the prior key.",
|
||||
},
|
||||
{
|
||||
n: "L3",
|
||||
t: "Timed escrow",
|
||||
d: "Funds sit in a cooling state until ship proof + buyer silence or explicit release. Timeout paths are deterministic per SKU class.",
|
||||
},
|
||||
{
|
||||
n: "L4",
|
||||
t: "Council multisig",
|
||||
d: "Escalations bind three moderators from disjoint rings; two-of-three releases or refunds with on-ledger rationale stubs.",
|
||||
},
|
||||
].map((x) => (
|
||||
<article key={x.n} className="rounded border border-[#00ff41]/20 bg-[#040806]/80 p-5">
|
||||
<span className="text-[10px] font-bold text-[#00ff41]/50">{x.n}</span>
|
||||
<h3 className="mt-1 text-sm font-bold text-[#b4ffcc]">{x.t}</h3>
|
||||
<p className="mt-2 text-xs leading-relaxed text-[#6a9b7a]">{x.d}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/15 bg-[#040806]/90 p-6">
|
||||
<h2 className="text-xs font-black uppercase tracking-widest text-[#00ff41]/60">Lexicon · settlement vocabulary</h2>
|
||||
<p className="mt-3 text-xs leading-relaxed text-[#6a9b7a]">
|
||||
<strong className="text-[#b4ffcc]">Watch-only wallet</strong> fiction for buyers who monitor UTXO flows without signing.{" "}
|
||||
<strong className="text-[#b4ffcc]">Replace-by-fee</strong> mentioned only as a cautionary tale in checkout copy.{" "}
|
||||
<strong className="text-[#b4ffcc]">Ring signatures</strong> referenced when explaining why Monero lanes default for privacy-marked SKUs.{" "}
|
||||
<strong className="text-[#b4ffcc]">Airgapped signing</strong> for high-bond vendors rotating keys.{" "}
|
||||
<strong className="text-[#b4ffcc]">Descriptor rotation</strong> ties vendor announcements to prior fingerprints so phishing clones stand out on any onion mirror.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded border border-[#00ff41]/25 p-6 md:p-8">
|
||||
<h2 className="text-sm font-black uppercase tracking-widest text-[#9dffc4]">Key hygiene checklist</h2>
|
||||
<ul className="mt-4 grid gap-3 text-xs leading-relaxed text-[#7ab896] md:grid-cols-2">
|
||||
<li>Verify signed mirror bundles against the hub-published list before pasting addresses.</li>
|
||||
<li>Never reuse a wallet label across stalls; phishing copies love reused nicknames.</li>
|
||||
<li>Treat “urgent finalization” messages as untrusted until the order id matches your session.</li>
|
||||
<li>Prefer XMR paths when the listing marks privacy-critical; BTC fiction still simulates fee spikes.</li>
|
||||
</ul>
|
||||
<p className="mt-6 text-[10px] uppercase tracking-wider text-[#00ff41]/40">
|
||||
Related:{" "}
|
||||
<Link href="/market/intel" className="text-[#7af598] hover:underline">
|
||||
Buyer intel
|
||||
</Link>
|
||||
·{" "}
|
||||
<Link href="/support" className="text-[#7af598] hover:underline">
|
||||
Support ladder
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
app/messages/page.tsx
Normal file
163
app/messages/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
|
||||
type Msg = { id: string; from: string; text: string; ts: number };
|
||||
|
||||
export default function MessagesPage() {
|
||||
const { user, hydrated } = useAccount();
|
||||
const handle = user?.username ?? null;
|
||||
|
||||
const [msgs, setMsgs] = useState<Msg[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
const endRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchMessages = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/messages?channel=global");
|
||||
const data = await res.json();
|
||||
if (data.ok && data.messages) {
|
||||
setMsgs(data.messages);
|
||||
}
|
||||
setReady(true);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
fetchMessages();
|
||||
const interval = setInterval(fetchMessages, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [hydrated, fetchMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
endRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [msgs, ready]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
setInput("");
|
||||
|
||||
const optimisticMsg: Msg = { id: "temp-" + Date.now(), from: handle ?? "anon", text, ts: Date.now() };
|
||||
setMsgs((p) => [...p, optimisticMsg]);
|
||||
|
||||
try {
|
||||
await fetch("/api/messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ from: handle ?? "anon", text, channel: "global" }),
|
||||
});
|
||||
fetchMessages();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ts = (n: number) =>
|
||||
new Date(n).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
return (
|
||||
<Sections>
|
||||
<section className="container mx-auto max-w-2xl px-4 py-12">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-orbitron text-2xl font-bold theme-accent">GLOBAL COMMS</h1>
|
||||
<p className="mt-1 text-xs text-foreground/50">
|
||||
{handle ? (
|
||||
<>Channel: <span className="text-neon-cyan">@{handle}</span> · synced across network</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/sign-in?next=/messages" className="text-neon-cyan underline">
|
||||
Sign in
|
||||
</Link>{" "}
|
||||
to persist messages to your handle
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-card rounded-2xl p-1">
|
||||
<div className="h-[420px] overflow-y-auto rounded-xl p-4 space-y-3">
|
||||
{!ready ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-foreground/40">Loading…</div>
|
||||
) : msgs.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-foreground/40">No messages yet. Be the first!</div>
|
||||
) : (
|
||||
msgs.map((m) => {
|
||||
const mine = m.from === handle || (m.from === "anon" && !handle);
|
||||
return (
|
||||
<div key={m.id} className={`flex flex-col ${mine ? "items-end" : "items-start"}`}>
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<span className={`text-xs font-medium ${mine ? "text-neon-cyan" : "text-foreground/60"}`}>
|
||||
@{m.from}
|
||||
</span>
|
||||
<span className="text-[10px] text-foreground/30">{ts(m.ts)}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-2xl px-4 py-2 text-sm ${
|
||||
mine ? "bg-neon-cyan/15 text-neon-cyan" : "bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-3 p-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
placeholder={ready ? "Message the network…" : "Loading…"}
|
||||
disabled={!ready}
|
||||
className="flex-1 rounded-xl border border-white/10 bg-transparent px-4 py-3 text-sm focus:border-neon-cyan/40 focus:outline-none disabled:opacity-50"
|
||||
maxLength={500}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={!input.trim() || !ready}
|
||||
className="rounded-xl bg-gradient-to-r from-neon-cyan to-neon-purple px-5 py-3 text-sm font-bold text-background disabled:opacity-40"
|
||||
>
|
||||
SEND
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-3 text-[10px] text-foreground/40 font-mono">
|
||||
<div className="theme-card rounded-xl p-3 text-center">Global synced storage</div>
|
||||
<div className="theme-card rounded-xl p-3 text-center">Real-time transport</div>
|
||||
<div className="theme-card rounded-xl p-3 text-center">Encrypted at rest</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/forum" className="text-xs text-neon-cyan hover:underline">Forum (persisted threads) →</Link>
|
||||
<Link href="/barter" className="text-xs text-foreground/50 hover:text-foreground/80">Barter board</Link>
|
||||
<Link href="/dashboard" className="text-xs text-foreground/50 hover:text-foreground/80">Dashboard</Link>
|
||||
</div>
|
||||
</section>
|
||||
</Sections>
|
||||
);
|
||||
}
|
||||
|
||||
function Sections({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ThemedLayout theme="terminal" siteTitle="ENCRYPTED COMMS" siteSubtitle="Global channel">
|
||||
{children}
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
197
app/mixer/page.tsx
Normal file
197
app/mixer/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
import { useWallet } from "@/contexts/WalletContext";
|
||||
|
||||
type MixLog = { id: string; text: string };
|
||||
|
||||
function rid() {
|
||||
return Math.random().toString(16).slice(2, 10).toUpperCase();
|
||||
}
|
||||
|
||||
export default function MixerPage() {
|
||||
const { user, hydrated } = useAccount();
|
||||
const { luxCredits, spendLuxCredits, setVaultFlag, addVaultReceipt } = useWallet();
|
||||
const [amount, setAmount] = useState(500);
|
||||
const [hops, setHops] = useState(3);
|
||||
const [noise, setNoise] = useState(65);
|
||||
const [log, setLog] = useState<MixLog[]>([]);
|
||||
|
||||
const relayReady = Boolean(hydrated && user);
|
||||
|
||||
const fee = useMemo(() => {
|
||||
const base = Math.floor(amount * 0.04);
|
||||
const hopFee = hops * 25;
|
||||
const noiseFee = Math.floor(noise * 2);
|
||||
return base + hopFee + noiseFee;
|
||||
}, [amount, hops, noise]);
|
||||
|
||||
const total = amount + fee;
|
||||
|
||||
const push = (text: string) => setLog((l) => [{ id: rid(), text }, ...l].slice(0, 10));
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="terminal" siteTitle="COIN BLENDER" siteSubtitle="Onion-routed blend pipeline">
|
||||
<section className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold md:text-5xl">
|
||||
COIN <span className="theme-accent">BLENDER</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-3xl text-xl opacity-90">
|
||||
Terminal theatre: dramatic hops, noise, and logs — while LUX debits for real on your handle.
|
||||
</p>
|
||||
<p className="mx-auto mt-3 max-w-3xl text-sm opacity-70">
|
||||
No outbound chain traffic from this page. Spending Bitcoin-backed USD happens at{" "}
|
||||
<Link href="/account/add-funds" className="theme-accent underline">
|
||||
Add funds
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/checkout" className="theme-accent underline">
|
||||
Checkout
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
<div className="theme-card rounded-3xl border border-white/10 p-8 lg:col-span-2">
|
||||
<div className="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-foreground/40">available</div>
|
||||
<div className="font-orbitron text-4xl font-bold text-neon-green">
|
||||
{luxCredits.toLocaleString()} LUX
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-foreground/60">
|
||||
{relayReady ? (
|
||||
<span className="text-neon-green">Relay: @{user!.username}</span>
|
||||
) : (
|
||||
<span>
|
||||
<Link href="/sign-in" className="theme-accent underline">
|
||||
Sign in
|
||||
</Link>{" "}
|
||||
to burn LUX
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="theme-card rounded-2xl border border-white/10 p-6">
|
||||
<div className="mb-2 font-bold">Amount (LUX)</div>
|
||||
<input
|
||||
type="range"
|
||||
min={50}
|
||||
max={2500}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(parseInt(e.target.value, 10))}
|
||||
className="w-full"
|
||||
disabled={!relayReady}
|
||||
/>
|
||||
<div className="mt-2 text-foreground/70">{amount.toLocaleString()} LUX</div>
|
||||
|
||||
<div className="mt-6 mb-2 font-bold">Route hops</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
disabled={!relayReady}
|
||||
onClick={() => setHops(n)}
|
||||
className={`rounded-xl border px-3 py-2 text-sm font-bold ${
|
||||
hops === n ? "border-neon-cyan bg-neon-cyan/10" : "border-white/10 bg-white/5"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 mb-2 font-bold">Plausible deniability noise</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={noise}
|
||||
onChange={(e) => setNoise(parseInt(e.target.value, 10))}
|
||||
className="w-full"
|
||||
disabled={!relayReady}
|
||||
/>
|
||||
<div className="mt-2 text-foreground/70">{noise}% nonsense</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-card rounded-2xl border border-white/10 p-6">
|
||||
<div className="mb-4 font-orbitron text-2xl font-bold">Receipt</div>
|
||||
<div className="space-y-3 text-sm text-foreground/70">
|
||||
<div className="flex justify-between">
|
||||
<span>Blend amount</span>
|
||||
<span className="font-bold">{amount.toLocaleString()} LUX</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Obfuscation fee</span>
|
||||
<span className="font-bold text-neon-pink">{fee.toLocaleString()} LUX</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-white/10 pt-3">
|
||||
<span>Total burn</span>
|
||||
<span className="font-orbitron font-bold text-neon-pink">{total.toLocaleString()} LUX</span>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl bg-white/5 p-4 text-xs text-foreground/60">
|
||||
LUX is loyalty currency tied to your account (earn some on completed checkouts). This UI is
|
||||
deliberately theatrical — the ledger write to your vault is real.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!relayReady}
|
||||
className="mt-6 w-full rounded-full bg-gradient-to-r from-neon-purple to-neon-pink px-6 py-3 font-bold text-background disabled:opacity-40"
|
||||
onClick={() => {
|
||||
const ok = spendLuxCredits(total);
|
||||
if (!ok) {
|
||||
push("Blend failed: insufficient LUX.");
|
||||
return;
|
||||
}
|
||||
setVaultFlag("blendedOnce", true);
|
||||
addVaultReceipt({
|
||||
id: `MX-${rid()}`,
|
||||
title: "Blender session",
|
||||
desc: `${amount.toLocaleString()} LUX fee burn (${hops} hops, ${noise}% noise).`,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
severity: "normal",
|
||||
});
|
||||
push(`Session ${rid()}: accepted. Injecting ${noise}% noise…`);
|
||||
push(`Session ${rid()}: routing through ${hops} hops…`);
|
||||
push(`Session ${rid()}: complete. LUX debited; vault receipt stored.`);
|
||||
}}
|
||||
>
|
||||
BLEND NOW 🌀
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-card rounded-3xl border border-white/10 p-8">
|
||||
<div className="font-orbitron text-2xl font-bold">Console</div>
|
||||
<div className="mt-6 space-y-3">
|
||||
{log.length === 0 ? (
|
||||
<div className="text-foreground/60">No sessions yet.</div>
|
||||
) : (
|
||||
log.map((e) => (
|
||||
<div
|
||||
key={e.id}
|
||||
className="rounded-xl border border-white/10 bg-black/40 p-4 font-mono text-xs text-neon-green"
|
||||
>
|
||||
{e.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
150
app/nexus/page.tsx
Normal file
150
app/nexus/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
const NODES = [
|
||||
{ id: "n1", name: "Void Exchange", onion: "voidexxxxx…onion", category: "Market", status: "up", uptime: "99.1%", last: "12s ago", guard: true },
|
||||
{ id: "n2", name: "Phantom Library", onion: "phantomll…onion", category: "Wiki", status: "up", uptime: "97.4%", last: "2m ago", guard: false },
|
||||
{ id: "n3", name: "Ghost Relay #7", onion: "ghostrly7…onion", category: "Relay", status: "up", uptime: "99.8%", last: "5s ago", guard: true },
|
||||
{ id: "n4", name: "Ember Forum", onion: "emberfrum…onion", category: "Forum", status: "down", uptime: "81.2%", last: "4h ago", guard: false },
|
||||
{ id: "n5", name: "Cipher Drop", onion: "ciphdrpxx…onion", category: "Drop", status: "up", uptime: "95.5%", last: "30s ago", guard: false },
|
||||
{ id: "n6", name: "Zero Wiki", onion: "zerowikxx…onion", category: "Wiki", status: "degraded", uptime: "91.0%", last: "8m ago", guard: true },
|
||||
{ id: "n7", name: "Dark Market", onion: "drkmarket…onion", category: "Market", status: "up", uptime: "98.3%", last: "18s ago", guard: true },
|
||||
{ id: "n8", name: "Nexus Gate", onion: "nexusgte…onion", category: "Relay", status: "up", uptime: "99.9%", last: "2s ago", guard: true },
|
||||
];
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
up: "text-neon-green bg-neon-green/10 border-neon-green/30",
|
||||
down: "text-red-400 bg-red-500/10 border-red-500/30",
|
||||
degraded: "text-yellow-400 bg-yellow-500/10 border-yellow-500/30",
|
||||
};
|
||||
const STATUS_DOT: Record<string, string> = {
|
||||
up: "bg-neon-green",
|
||||
down: "bg-red-500",
|
||||
degraded: "bg-yellow-400",
|
||||
};
|
||||
|
||||
const CATS = ["All", "Market", "Wiki", "Forum", "Relay", "Drop"];
|
||||
|
||||
export default function NexusPage() {
|
||||
const [cat, setCat] = useState("All");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = NODES.filter((n) => {
|
||||
const matchCat = cat === "All" || n.category === cat;
|
||||
const matchSearch = !search || n.name.toLowerCase().includes(search.toLowerCase()) || n.onion.includes(search.toLowerCase());
|
||||
return matchCat && matchSearch;
|
||||
});
|
||||
|
||||
const up = NODES.filter((n) => n.status === "up").length;
|
||||
const down = NODES.filter((n) => n.status === "down").length;
|
||||
const degraded = NODES.filter((n) => n.status === "degraded").length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-[#0a0a0a] pt-24 pb-20">
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-10">
|
||||
<p className="mb-2 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">nexus · hidden service network tracker</p>
|
||||
<h1 className="font-orbitron text-4xl font-bold md:text-5xl">
|
||||
THE <span className="text-neon-cyan">NEXUS</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-foreground/60">
|
||||
Real-time monitoring of trusted hidden services across the Tor network. Submit your node to join the monitored ring.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Status overview */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-8">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{[
|
||||
{ label: "Online", count: up, color: "neon-green" },
|
||||
{ label: "Degraded", count: degraded, color: "yellow-400" },
|
||||
{ label: "Offline", count: down, color: "red-400" },
|
||||
].map((s) => (
|
||||
<div key={s.label} className="flex items-center gap-3 rounded-xl border border-zinc-700/40 bg-black/40 px-5 py-3">
|
||||
<span className={`h-2.5 w-2.5 rounded-full bg-${s.color} animate-pulse`} />
|
||||
<div>
|
||||
<p className="text-xl font-bold font-orbitron">{s.count}</p>
|
||||
<p className="text-xs text-foreground/40">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Filters */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
className="rounded-lg border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm text-foreground placeholder-foreground/30 w-56 focus:border-neon-cyan/40 outline-none"
|
||||
placeholder="Search nodes…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCat(c)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold border transition ${cat === c ? "border-neon-cyan/50 bg-neon-cyan/10 text-neon-cyan" : "border-zinc-700 bg-zinc-900 text-foreground/60 hover:border-zinc-500"}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Node table */}
|
||||
<section className="container mx-auto max-w-6xl px-4">
|
||||
<div className="overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-800 bg-black/60">
|
||||
<tr>
|
||||
{["Node", "Category", "Onion", "Status", "Uptime", "Last seen", "Guard"].map((h) => (
|
||||
<th key={h} className="px-4 py-3 text-left text-[10px] uppercase tracking-wider text-foreground/40">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((node, i) => (
|
||||
<tr key={node.id} className={`border-b border-zinc-800/50 ${i % 2 === 0 ? "bg-black/20" : "bg-black/40"} hover:bg-white/5 transition`}>
|
||||
<td className="px-4 py-3 font-semibold text-foreground">{node.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="rounded-full border border-zinc-600 bg-zinc-800 px-2 py-0.5 text-[9px] uppercase tracking-wider text-zinc-400">{node.category}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground/50">{node.onion}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-bold ${STATUS_COLORS[node.status]}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${STATUS_DOT[node.status]}`} />
|
||||
{node.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-foreground/70">{node.uptime}</td>
|
||||
<td className="px-4 py-3 text-xs text-foreground/40">{node.last}</td>
|
||||
<td className="px-4 py-3 text-center">{node.guard ? <span className="text-neon-cyan text-base">✓</span> : <span className="text-foreground/20">—</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Submit */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mt-12">
|
||||
<div className="rounded-2xl border border-neon-cyan/20 bg-neon-cyan/5 p-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 className="font-orbitron text-lg font-bold">Submit your node</h2>
|
||||
<p className="text-sm text-foreground/60 mt-1">Add your hidden service to the Nexus monitoring ring. Requires a signed verification message.</p>
|
||||
</div>
|
||||
<button className="shrink-0 rounded-lg border border-neon-cyan/40 bg-neon-cyan/10 px-5 py-2.5 text-sm font-bold text-neon-cyan hover:bg-neon-cyan/20 transition">
|
||||
Submit Node →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
app/oracle/page.tsx
Normal file
134
app/oracle/page.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Navbar from "@/components/Navbar";
|
||||
|
||||
const LISTINGS = [
|
||||
{ id: "1", title: "Verified exit scam early warning — active market", category: "Market Intel", price: 8, verified: true, age: "2h ago", rep: 94 },
|
||||
{ id: "2", title: "Opsec hole in popular forum software (CVE-pending)", category: "Vulnerability", price: 25, verified: true, age: "6h ago", rep: 88 },
|
||||
{ id: "3", title: "Darknet exchange front-running scheme — evidence package", category: "Fraud Intel", price: 15, verified: false, age: "1d ago", rep: 72 },
|
||||
{ id: "4", title: "Law enforcement infrastructure node IPs (EU)", category: "Counter-Intel", price: 40, verified: true, age: "2d ago", rep: 91 },
|
||||
{ id: "5", title: "Compromised vendor PGP key — do not transact", category: "Crypto", price: 5, verified: true, age: "3h ago", rep: 99 },
|
||||
{ id: "6", title: "Tor relay operator correlation attack proof-of-concept", category: "Network", price: 30, verified: false, age: "5d ago", rep: 65 },
|
||||
];
|
||||
|
||||
const CATS = ["All", "Market Intel", "Vulnerability", "Fraud Intel", "Counter-Intel", "Crypto", "Network"];
|
||||
|
||||
export default function OraclePage() {
|
||||
const [cat, setCat] = useState("All");
|
||||
|
||||
const filtered = cat === "All" ? LISTINGS : LISTINGS.filter((l) => l.category === cat);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="min-h-screen bg-[#0a0a0a] pt-24 pb-20">
|
||||
{/* Header */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-12">
|
||||
<p className="mb-2 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/70">
|
||||
the oracle · verified intelligence market
|
||||
</p>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 className="font-orbitron text-4xl font-bold md:text-5xl">
|
||||
THE <span className="text-neon-purple">ORACLE</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-foreground/60">
|
||||
Buy and sell verified darknet intelligence. Listings are staked — submitters put VOID credits at risk. Community verification ensures accuracy.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/sign-in" className="rounded-lg border border-neon-purple/40 bg-neon-purple/10 px-4 py-2 text-sm font-bold text-neon-purple hover:bg-neon-purple/20">
|
||||
Submit intel →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats bar */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-8">
|
||||
<div className="flex flex-wrap gap-6 rounded-xl border border-zinc-800 bg-black/40 p-4 text-sm">
|
||||
{[
|
||||
{ label: "Active listings", value: LISTINGS.length },
|
||||
{ label: "Verified", value: LISTINGS.filter((l) => l.verified).length },
|
||||
{ label: "Avg price", value: `✦ ${Math.round(LISTINGS.reduce((a, l) => a + l.price, 0) / LISTINGS.length)} VOID` },
|
||||
{ label: "Avg rep score", value: `${Math.round(LISTINGS.reduce((a, l) => a + l.rep, 0) / LISTINGS.length)}%` },
|
||||
].map((s) => (
|
||||
<div key={s.label}>
|
||||
<p className="text-xs text-foreground/40 uppercase tracking-wider">{s.label}</p>
|
||||
<p className="font-bold text-foreground">{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Category filter */}
|
||||
<section className="container mx-auto max-w-6xl px-4 mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setCat(c)}
|
||||
className={`rounded-full px-4 py-1.5 text-xs font-semibold border transition ${
|
||||
cat === c
|
||||
? "border-neon-purple/60 bg-neon-purple/20 text-neon-purple"
|
||||
: "border-zinc-700 bg-zinc-900 text-foreground/60 hover:border-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Listings */}
|
||||
<section className="container mx-auto max-w-6xl px-4">
|
||||
<div className="space-y-3">
|
||||
{filtered.map((listing) => (
|
||||
<div
|
||||
key={listing.id}
|
||||
className="flex flex-col gap-3 rounded-xl border border-zinc-700/40 bg-black/40 p-5 sm:flex-row sm:items-center hover:border-zinc-500 transition cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="rounded-full border border-zinc-600 bg-zinc-800 px-2 py-0.5 text-[9px] uppercase tracking-wider text-zinc-400">
|
||||
{listing.category}
|
||||
</span>
|
||||
{listing.verified && (
|
||||
<span className="rounded-full border border-neon-cyan/30 bg-neon-cyan/10 px-2 py-0.5 text-[9px] uppercase tracking-wider text-neon-cyan">
|
||||
✓ verified
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-foreground/30">{listing.age}</span>
|
||||
</div>
|
||||
<p className="font-semibold text-foreground text-sm leading-snug">{listing.title}</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<span className="text-xs text-foreground/40">Rep: <span className={listing.rep >= 85 ? "text-neon-green" : listing.rep >= 65 ? "text-yellow-500" : "text-red-400"}>{listing.rep}%</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="font-mono text-base font-bold text-neon-purple">✦ {listing.price}</span>
|
||||
<button className="rounded-lg border border-neon-purple/40 bg-neon-purple/10 px-4 py-1.5 text-xs font-bold text-neon-purple hover:bg-neon-purple/20 transition">
|
||||
Purchase
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Submit box */}
|
||||
<section className="container mx-auto max-w-3xl px-4 mt-16">
|
||||
<div className="rounded-2xl border border-neon-purple/20 bg-neon-purple/5 p-8 text-center">
|
||||
<h2 className="font-orbitron text-xl font-bold mb-2">Have actionable intelligence?</h2>
|
||||
<p className="text-sm text-foreground/60 mb-5">Submit a listing. Stake VOID credits to guarantee accuracy — payouts on verified buys.</p>
|
||||
<Link href="/sign-in" className="inline-block rounded-full bg-neon-purple/20 border border-neon-purple/40 px-6 py-2.5 text-sm font-bold text-neon-purple hover:bg-neon-purple/30">
|
||||
Submit Intelligence →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
308
app/page.tsx
Normal file
308
app/page.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Hero from "@/components/Hero";
|
||||
import ProductCard from "@/components/ProductCard";
|
||||
import ParticleBackground from "@/components/ParticleBackground";
|
||||
import EncryptionDemo from "@/components/EncryptionDemo";
|
||||
import ForumBoard from "@/components/ForumBoard";
|
||||
import AccountCreation from "@/components/AccountCreation";
|
||||
import ChatWidget from "@/components/ChatWidget";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
SHOP_PRODUCT_COUNT,
|
||||
featuredShopProducts,
|
||||
resolveProductImage,
|
||||
shopCategoryCounts,
|
||||
} from "@/lib/shopCatalog";
|
||||
import { SHOP_SELLER_COUNT, getSellerById } from "@/lib/shopSellers";
|
||||
|
||||
export default function Home() {
|
||||
const categories = shopCategoryCounts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ParticleBackground />
|
||||
<Navbar />
|
||||
<Hero />
|
||||
|
||||
{/* Featured Products */}
|
||||
<section id="featured" className="container mx-auto max-w-6xl scroll-mt-24 px-4 py-20">
|
||||
<div className="mb-12 flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-xl">
|
||||
<p className="mb-2 font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/70">spotlight</p>
|
||||
<h2 className="font-orbitron text-3xl font-bold md:text-4xl">ARCANE ARTIFACTS</h2>
|
||||
<p className="mt-3 text-foreground/60">
|
||||
Conjured relics from <strong className="text-neon-cyan">{SHOP_PRODUCT_COUNT}</strong> in-stock tomes ·{" "}
|
||||
<Link href="/vendors" className="text-neon-purple underline hover:text-neon-cyan">
|
||||
{SHOP_SELLER_COUNT} alchemist guilds
|
||||
</Link>
|
||||
. Verify runic signatures on the astral mirror list before your first blood pact.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<Link
|
||||
href="#categories"
|
||||
className="glass rounded-full px-6 py-3 text-center text-sm font-medium transition-colors hover:border-neon-cyan/40"
|
||||
>
|
||||
Jump to categories
|
||||
</Link>
|
||||
<Link
|
||||
href="/market"
|
||||
className="rounded-full bg-gradient-to-r from-neon-cyan to-neon-purple px-6 py-3 text-center text-sm font-bold text-background"
|
||||
>
|
||||
VIEW ALL {SHOP_PRODUCT_COUNT} SKUS
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{featuredShopProducts(8).map((p) => {
|
||||
const v = getSellerById(p.sellerId)!;
|
||||
return (
|
||||
<ProductCard
|
||||
key={p.id}
|
||||
shopProduct={p}
|
||||
id={p.id}
|
||||
name={p.name}
|
||||
description={p.description}
|
||||
price={p.price}
|
||||
currency={p.currency}
|
||||
category={p.category}
|
||||
imageUrl={resolveProductImage(p)}
|
||||
limited={p.limited}
|
||||
glowColor={p.glowColor}
|
||||
sellerHandle={v.handle}
|
||||
sellerSlug={v.slug}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Category Themes */}
|
||||
<section id="categories" className="container mx-auto max-w-6xl px-4 py-20 scroll-mt-24">
|
||||
<p className="mb-2 text-center font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/70">summon</p>
|
||||
<h2 className="mb-10 text-center font-orbitron text-3xl font-bold md:text-4xl">SCHOOLS OF MAGIC</h2>
|
||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-5">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.name}
|
||||
href={`/market?category=${encodeURIComponent(cat.name)}`}
|
||||
className="glass group relative block overflow-hidden rounded-2xl border border-white/10 p-6 text-center backdrop-blur-sm transition-all hover:scale-105 hover:border-neon-cyan"
|
||||
>
|
||||
<div className="mb-4 text-4xl">{cat.icon}</div>
|
||||
<h3 className="mb-2 font-bold">{cat.name}</h3>
|
||||
<div className="text-sm text-foreground/50">{cat.count} items</div>
|
||||
<div className="absolute -inset-1 -z-10 bg-gradient-to-br from-neon-cyan/0 to-neon-purple/0 opacity-0 transition-opacity group-hover:opacity-20"></div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Vault Feature */}
|
||||
<section id="vault" className="container mx-auto max-w-6xl px-4 py-20">
|
||||
<div className="glass overflow-hidden rounded-3xl border border-white/10 p-10 md:p-16">
|
||||
<div className="grid items-center gap-10 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="font-orbitron text-4xl font-bold">THE GRIMOIRE</h2>
|
||||
<p className="my-6 text-xl text-foreground/80">
|
||||
A secure, enchanted space for initiated warlocks. Store spectral receipts, unlock dark rewards, and access forbidden spells.
|
||||
</p>
|
||||
<Link href="/vault" className="rounded-full bg-gradient-to-r from-neon-green to-neon-cyan px-8 py-4 font-bold text-background inline-block">
|
||||
OPEN YOUR GRIMOIRE
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-4 rounded-3xl bg-gradient-to-r from-neon-green/20 to-neon-cyan/20 blur-3xl"></div>
|
||||
<div className="relative rounded-2xl border border-neon-green/30 bg-black/50 p-8">
|
||||
<div className="font-orbitron text-2xl font-bold text-neon-green">VAULT STATUS: ACTIVE</div>
|
||||
<div className="my-4 h-2 rounded-full bg-white/10">
|
||||
<div className="h-full w-3/4 rounded-full bg-gradient-to-r from-neon-green to-neon-cyan"></div>
|
||||
</div>
|
||||
<div className="text-sm text-foreground/60">Encryption level: Quantum‑256</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Encryption Demo */}
|
||||
<section id="encryption" className="container mx-auto max-w-6xl px-4 py-20">
|
||||
<div className="mb-12 text-center">
|
||||
<h2 className="font-orbitron text-4xl font-bold">ENCRYPTION SUITE</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-foreground/70">
|
||||
Experience the power of client‑side encryption. Your data stays yours.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<EncryptionDemo />
|
||||
<div className="glass rounded-2xl border border-white/10 p-8">
|
||||
<h3 className="mb-6 font-orbitron text-2xl font-bold">HOW IT ACTUALLY WORKS</h3>
|
||||
<ul className="space-y-6">
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="text-2xl">🔑</div>
|
||||
<div>
|
||||
<div className="font-bold">Client-side account store</div>
|
||||
<div className="text-sm text-foreground/60">Passwords are hashed in-browser. No plaintext ever sent to a server. Accounts live in your localStorage.</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="text-2xl">₿</div>
|
||||
<div>
|
||||
<div className="font-bold">Bitcoin on-chain verify</div>
|
||||
<div className="text-sm text-foreground/60">Deposits verified against mempool.space. USD credit applied at CoinGecko spot rate after 1 confirmation.</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="text-2xl">🧅</div>
|
||||
<div>
|
||||
<div className="font-bold">Tor-deployable architecture</div>
|
||||
<div className="text-sm text-foreground/60">Each .onion is a separate Nginx virtual host looped to the same Next.js process. Documented in DEPLOY.md.</div>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-4">
|
||||
<div className="text-2xl">📦</div>
|
||||
<div>
|
||||
<div className="font-bold">Portable identity bundle</div>
|
||||
<div className="text-sm text-foreground/60">Export your account from /dashboard to carry your handle and vault across .onion deployments.</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="glass rounded-3xl border border-neon-cyan/25 p-8 md:p-10">
|
||||
<h2 className="font-orbitron text-2xl font-bold md:text-3xl">
|
||||
Multi‑onion <span className="text-neon-cyan">network</span>
|
||||
</h2>
|
||||
<p className="mt-3 max-w-3xl text-foreground/75">
|
||||
CyberLux runs as a federated ring: this host is the <strong>primary cart</strong>; companion onions
|
||||
expose <strong>forums</strong>, <strong>classifieds</strong>, and <strong>wiki</strong> entry points.
|
||||
Wallet and checkout stay reachable from every mirror — always confirm the hostname against the
|
||||
latest signed canary before you fund a session.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-4">
|
||||
<Link
|
||||
href="/forum"
|
||||
className="rounded-full border border-neon-cyan bg-neon-cyan/10 px-6 py-2 text-sm font-bold text-neon-cyan hover:bg-neon-cyan/20"
|
||||
>
|
||||
Forum platform →
|
||||
</Link>
|
||||
<Link
|
||||
href="/chatter"
|
||||
className="rounded-full border border-neon-purple/40 bg-neon-purple/10 px-6 py-2 text-sm font-bold text-neon-purple hover:bg-neon-purple/20"
|
||||
>
|
||||
Hub chatter archive →
|
||||
</Link>
|
||||
<Link
|
||||
href="/exchange"
|
||||
className="rounded-full border border-neon-green bg-neon-green/10 px-6 py-2 text-sm font-bold text-neon-green hover:bg-neon-green/20"
|
||||
>
|
||||
Buy / sell exchange →
|
||||
</Link>
|
||||
<Link
|
||||
href="/barter"
|
||||
className="rounded-full border border-emerald-500/50 bg-emerald-500/10 px-6 py-2 text-sm font-bold text-emerald-400 hover:bg-emerald-500/20"
|
||||
>
|
||||
Ash Pit barter floor →
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className="rounded-full border border-[#00ff41]/40 bg-[#00ff41]/10 px-6 py-2 text-sm font-bold text-[#7af598] hover:bg-[#00ff41]/20"
|
||||
>
|
||||
Void crawler (internal search) →
|
||||
</Link>
|
||||
<Link
|
||||
href="/hidden-wiki"
|
||||
className="rounded-full border border-[#3d5a80]/50 bg-[#1a2740]/40 px-6 py-2 text-sm font-bold text-[#7eb8ff] hover:border-[#5b8def]/60"
|
||||
>
|
||||
Hidden wiki →
|
||||
</Link>
|
||||
<Link
|
||||
href="/market"
|
||||
className="rounded-full border border-white/20 px-6 py-2 text-sm font-medium hover:border-neon-purple/50"
|
||||
>
|
||||
Candy market →
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-foreground/45">
|
||||
New relay keys publish weekly. Cross-check <code className="rounded bg-white/10 px-1">sha256</code>{" "}
|
||||
fingerprints on the vendor PGP bundle — impostor front domains are common this season.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Forum */}
|
||||
<section id="forum" className="container mx-auto max-w-6xl px-4 py-20">
|
||||
<ForumBoard />
|
||||
</section>
|
||||
|
||||
{/* Account Creation */}
|
||||
<section id="account" className="container mx-auto max-w-6xl px-4 py-20">
|
||||
<AccountCreation />
|
||||
</section>
|
||||
|
||||
{/* Encrypted Chat */}
|
||||
<section id="chat" className="container mx-auto max-w-6xl px-4 py-20">
|
||||
<ChatWidget />
|
||||
</section>
|
||||
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="glass border-t border-white/10 px-4 py-12">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-neon-cyan to-neon-purple"></div>
|
||||
<div className="font-orbitron text-2xl font-bold">EldritchWeave</div>
|
||||
</div>
|
||||
<p className="text-foreground/60">
|
||||
A first‑of‑its‑kind dark magic hacker gathering.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold">EXPLORE</h3>
|
||||
<ul className="space-y-2 text-foreground/60">
|
||||
<li><Link href="/market" className="hover:text-neon-cyan">Market</Link></li>
|
||||
<li><Link href="/vendors" className="hover:text-neon-cyan">Vendor hall</Link></li>
|
||||
<li><Link href="/checkout" className="hover:text-neon-cyan">Checkout</Link></li>
|
||||
<li><Link href="/forum" className="hover:text-neon-cyan">Forum</Link></li>
|
||||
<li><Link href="/sanctuary" className="hover:text-neon-cyan">Sanctuary</Link></li>
|
||||
<li><Link href="/drops" className="hover:text-neon-cyan">Limited Drops</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold">TECHNOLOGY</h3>
|
||||
<ul className="space-y-2 text-foreground/60">
|
||||
<li><Link href="/checkout" className="hover:text-neon-cyan">Settlement & verify</Link></li>
|
||||
<li><Link href="/vault" className="hover:text-neon-cyan">Vault & keys</Link></li>
|
||||
<li><Link href="/trust" className="hover:text-neon-cyan">Trust metrics</Link></li>
|
||||
<li><Link href="/security-analysis" className="hover:text-neon-cyan">Security analysis</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold">CONTACT</h3>
|
||||
<p className="text-foreground/60">
|
||||
Encrypted messaging only.
|
||||
<br />
|
||||
<Link href="/support" className="text-neon-cyan hover:underline">Support / offering</Link>
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Link href="/messages" className="rounded-full bg-white/5 p-3 hover:bg-white/10" title="Messages">💬</Link>
|
||||
<Link href="/account/add-funds" className="rounded-full bg-white/5 p-3 hover:bg-white/10" title="Add Funds">₿</Link>
|
||||
<Link href="/vault" className="rounded-full bg-white/5 p-3 hover:bg-white/10" title="Vault">🔒</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 border-t border-white/10 pt-8 text-center text-sm text-foreground/45">
|
||||
<p>© 2026 EldritchWeave · Runic-signed mirrors · Blood pact mandatory for new summoners</p>
|
||||
<p className="mt-2 text-foreground/35">
|
||||
Finalize only after delivery. Report impostor realms with signed proof. Spells are user-generated — verify before you pay.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
93
app/presswire/page.tsx
Normal file
93
app/presswire/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
import { useWallet } from "@/contexts/WalletContext";
|
||||
|
||||
export default function PresswirePage() {
|
||||
const { vault } = useWallet();
|
||||
|
||||
const keyCount = vault.keys.length;
|
||||
const urgency =
|
||||
keyCount >= 10 ? "CODE BLACK" : keyCount >= 6 ? "ELEVATED" : keyCount >= 2 ? "MILD" : "NORMAL";
|
||||
|
||||
const headlines = [
|
||||
"Local Man Discovers New Way To Lose Money (Again)",
|
||||
"CyberLux Denies Allegations Of Selling “Canned Wi‑Fi”",
|
||||
"Exclusive: The Left Sock Was Always Quantum",
|
||||
"Breaking: Cat Facts Leak Into Public Internet, Experts Concerned",
|
||||
"Authorities Confirm Trees Are “Very Online” Now",
|
||||
];
|
||||
|
||||
return (
|
||||
<ThemedLayout
|
||||
theme="editorial"
|
||||
siteTitle="CyberLux Presswire"
|
||||
siteSubtitle="Independent journalism sponsored by vibes and paranoia"
|
||||
>
|
||||
<section className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold md:text-5xl">
|
||||
CYBERLUX <span className="theme-accent">PRESSWIRE</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-3xl text-xl opacity-90">
|
||||
Independent journalism sponsored by vibes, paranoia, and extremely clickable headlines.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="theme-card mt-12 rounded-3xl p-8 md:p-10">
|
||||
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-xs opacity-70">rumor heat</div>
|
||||
<div className="text-3xl font-bold theme-accent">{urgency}</div>
|
||||
<div className="mt-2 text-sm opacity-80">
|
||||
Your Vault currently contains <span className="font-bold">{keyCount}</span> keys.
|
||||
The newsroom is reacting normally (untrue).
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/webring"
|
||||
className="theme-accent rounded-full border-2 border-current bg-current px-6 py-3 font-bold text-white hover:opacity-90"
|
||||
>
|
||||
Chase rumors in the webring →
|
||||
</Link>
|
||||
<Link
|
||||
href="/vault"
|
||||
className="theme-card rounded-full border-2 border-current px-6 py-3 font-bold"
|
||||
>
|
||||
View Vault
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{headlines.map((h) => (
|
||||
<div key={h} className="theme-card rounded-2xl p-6 transition-all hover:shadow-lg">
|
||||
<div className="text-xs opacity-70">breaking</div>
|
||||
<div className="mt-2 font-bold text-lg">{h}</div>
|
||||
<div className="mt-3 text-sm opacity-80">
|
||||
Sources confirm this story is 80% dramatic tension and 20% an accidental CSS gradient.
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Link href="/market" className="theme-accent hover:underline">related: market</Link>
|
||||
<Link href="/game" className="theme-accent hover:underline">related: arcade</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="theme-card mt-10 rounded-2xl p-6 text-sm opacity-80">
|
||||
Editorial note: rumor heat is for atmosphere — verify everything against your own threat model and mirrors.
|
||||
For tree matters, consult the{" "}
|
||||
<Link className="theme-accent hover:underline" href="/trees">
|
||||
Trees Authority
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
|
||||
273
app/raffle/page.tsx
Normal file
273
app/raffle/page.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import PayWithUsdBalanceButton from "@/components/PayWithUsdBalanceButton";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
import { getMerchantBtcAddress, isMerchantBtcConfigured } from "@/lib/merchantBtc";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
|
||||
const PRIZES = [
|
||||
{ place: "1st", prize: "500 LUX Credits", sub: "Applied to your CyberLux handle." },
|
||||
{ place: "2nd", prize: "250 LUX Credits", sub: "Applied to your CyberLux handle." },
|
||||
{ place: "3rd", prize: "100 LUX Credits", sub: "Applied to your CyberLux handle." },
|
||||
{ place: "Runner-up ×5", prize: "Handler Pass", sub: "Digital access pass upgrade." },
|
||||
];
|
||||
|
||||
const DRAW_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||
const DRAW_EPOCH = new Date("2026-04-14T00:00:00Z").getTime();
|
||||
|
||||
function getNextDraw() {
|
||||
const now = Date.now();
|
||||
const elapsed = now - DRAW_EPOCH;
|
||||
const cycles = Math.floor(elapsed / DRAW_INTERVAL_MS);
|
||||
return DRAW_EPOCH + (cycles + 1) * DRAW_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function formatRemaining(ms: number) {
|
||||
if (ms <= 0) return "00:00:00";
|
||||
const totalSec = Math.floor(ms / 1000);
|
||||
const d = Math.floor(totalSec / 86400);
|
||||
const h = Math.floor((totalSec % 86400) / 3600);
|
||||
const m = Math.floor((totalSec % 3600) / 60);
|
||||
const s = totalSec % 60;
|
||||
if (d > 0) return `${d}d ${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
return `${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const TICKET_PRICE_BTC = "0.0001";
|
||||
const TICKET_ENTRY_KEY = "cyberlux-raffle-entries-v1";
|
||||
|
||||
type Entry = { handle: string; txid: string; ts: string };
|
||||
|
||||
function loadEntries(): Entry[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(TICKET_ENTRY_KEY) ?? "[]") as Entry[];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function saveEntries(e: Entry[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(TICKET_ENTRY_KEY, JSON.stringify(e));
|
||||
}
|
||||
|
||||
export default function RafflePage() {
|
||||
const { user } = useAccount();
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [txid, setTxid] = useState("");
|
||||
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
const [errMsg, setErrMsg] = useState("");
|
||||
const [btcUsd, setBtcUsd] = useState<number | null>(null);
|
||||
const btcAddr = getMerchantBtcAddress();
|
||||
const btcReady = isMerchantBtcConfigured();
|
||||
|
||||
const ticketUsd = useMemo(() => {
|
||||
const r = btcUsd ?? 96000;
|
||||
const v = parseFloat(TICKET_PRICE_BTC) * r;
|
||||
return Math.round(v * 100) / 100;
|
||||
}, [btcUsd]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
"https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const j = (await res.json()) as { bitcoin?: { usd?: number } };
|
||||
if (!cancelled && j.bitcoin?.usd && Number.isFinite(j.bitcoin.usd)) setBtcUsd(j.bitcoin.usd);
|
||||
} catch {
|
||||
if (!cancelled) setBtcUsd(96000);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const registerBalanceTicket = useCallback(() => {
|
||||
const t = `balance-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const entry: Entry = {
|
||||
handle: user?.username ?? "anon",
|
||||
txid: t,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
const cur = loadEntries();
|
||||
const updated = [...cur, entry];
|
||||
saveEntries(updated);
|
||||
setEntries(updated);
|
||||
setStatus("success");
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
}, [user?.username]);
|
||||
|
||||
useEffect(() => {
|
||||
setEntries(loadEntries());
|
||||
const tick = () => setRemaining(getNextDraw() - Date.now());
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const handleEnter = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const t = txid.trim();
|
||||
if (!t || t.length < 20) {
|
||||
setErrMsg("Enter a valid Bitcoin transaction ID.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
if (entries.some((x) => x.txid === t)) {
|
||||
setErrMsg("This txid has already been submitted.");
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
const entry: Entry = {
|
||||
handle: user?.username ?? "anon",
|
||||
txid: t,
|
||||
ts: new Date().toISOString(),
|
||||
};
|
||||
const updated = [...entries, entry];
|
||||
setEntries(updated);
|
||||
saveEntries(updated);
|
||||
setTxid("");
|
||||
setStatus("success");
|
||||
setTimeout(() => setStatus("idle"), 4000);
|
||||
},
|
||||
[txid, entries, user]
|
||||
);
|
||||
|
||||
const myEntries = user ? entries.filter((e) => e.handle === user.username) : [];
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="terminal" title="Shadow Raffle">
|
||||
<div className="mx-auto max-w-4xl px-4 pt-12 pb-20 font-mono text-white">
|
||||
<div className="border-4 border-white bg-[#000080] p-10 shadow-[10px_10px_0_rgba(255,255,255,0.15)]">
|
||||
<div className="mb-2 text-center text-[10px] uppercase tracking-widest text-white/50">
|
||||
⚠ Prize pool is in LUX credits — not BTC. Entry fee covers onchain raffle infrastructure.
|
||||
</div>
|
||||
<h1 className="mb-10 text-center text-4xl font-black uppercase tracking-tighter">
|
||||
Weekly Shadow Raffle
|
||||
</h1>
|
||||
|
||||
<div className="mb-10 grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<div className="border-2 border-white p-5 text-center">
|
||||
<div className="mb-1 text-[10px] uppercase text-white/60">Next Draw</div>
|
||||
<div className="text-2xl font-black tabular-nums text-white">
|
||||
{formatRemaining(remaining)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-2 border-white p-5 text-center">
|
||||
<div className="mb-1 text-[10px] uppercase text-white/60">Ticket Price</div>
|
||||
<div className="text-2xl font-black">{TICKET_PRICE_BTC} BTC</div>
|
||||
<div className="mt-1 text-[11px] text-[#00ff41]/80">≈ ${ticketUsd.toFixed(2)} USD @ spot</div>
|
||||
</div>
|
||||
<div className="border-2 border-white p-5 text-center">
|
||||
<div className="mb-1 text-[10px] uppercase text-white/60">Entries This Round</div>
|
||||
<div className="text-2xl font-black">{entries.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-10">
|
||||
<h2 className="mb-4 border-b-2 border-white pb-2 text-xl font-black uppercase">Prize Breakdown</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{PRIZES.map((p) => (
|
||||
<div key={p.place} className="border border-white/20 bg-white/5 p-5">
|
||||
<div className="text-lg font-black">{p.place}: {p.prize}</div>
|
||||
<p className="mt-1 text-xs text-white/60">{p.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-4 border-dashed border-white/40 p-8">
|
||||
<h3 className="mb-2 text-lg font-black uppercase">Enter Your Ticket</h3>
|
||||
<p className="mb-4 text-xs text-white/70 leading-relaxed">
|
||||
Send exactly {TICKET_PRICE_BTC} BTC to the address below. After at least 1 confirmation, paste your
|
||||
transaction ID here. Your handle is registered as an entrant. Draw happens at the countdown above.
|
||||
</p>
|
||||
|
||||
{btcReady ? (
|
||||
<div className="mb-5 break-all border-2 border-white bg-black px-4 py-3 font-mono text-xs text-[#00ff41]">
|
||||
{btcAddr}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-5 border-2 border-amber-500/50 bg-black px-4 py-3 text-xs text-amber-300">
|
||||
Merchant address not configured. Set NEXT_PUBLIC_MERCHANT_BTC_ADDRESS in .env.local.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleEnter} className="flex flex-col gap-4 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={txid}
|
||||
onChange={(e) => setTxid(e.target.value)}
|
||||
placeholder="Bitcoin transaction ID (64 hex chars)…"
|
||||
className="flex-1 border-2 border-white bg-black px-4 py-3 text-sm text-white focus:border-[#00ff41] focus:outline-none"
|
||||
maxLength={64}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!btcReady}
|
||||
className="border-2 border-white bg-white px-6 py-3 font-black uppercase text-black transition-all hover:bg-[#00ff41] disabled:opacity-40"
|
||||
>
|
||||
Register Entry
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status === "success" && (
|
||||
<p className="mt-3 text-sm text-[#00ff41]">✓ Entry registered for @{user?.username ?? "anon"}. Good luck.</p>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<p className="mt-3 text-sm text-red-400">{errMsg}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-2 border-[#00ff41]/40 bg-black/50 p-4">
|
||||
<h4 className="mb-2 text-sm font-black uppercase text-[#00ff41]">Pay with USD balance</h4>
|
||||
<p className="mb-3 text-[11px] text-white/60 leading-relaxed">
|
||||
Same ticket — charges your Bitcoin-funded USD balance at ≈ ${ticketUsd.toFixed(2)} (live BTC spot). No separate txid needed.
|
||||
</p>
|
||||
<PayWithUsdBalanceButton
|
||||
amountUsd={ticketUsd}
|
||||
title="Shadow Raffle ticket"
|
||||
description="Weekly draw entry"
|
||||
luxBonus={50}
|
||||
onSuccess={registerBalanceTicket}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
<p className="mt-3 text-xs text-white/50">
|
||||
<Link href="/sign-in?next=/raffle" className="text-[#00ff41] underline">Sign in</Link>{" "}
|
||||
to attach entries to your handle.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{myEntries.length > 0 && (
|
||||
<div className="mt-6 border border-white/20 p-4">
|
||||
<div className="mb-2 text-[10px] uppercase text-white/60">Your entries this round</div>
|
||||
{myEntries.map((e) => (
|
||||
<div key={e.txid} className="text-xs text-[#00ff41]/70 truncate">
|
||||
{e.txid.slice(0, 12)}…{e.txid.slice(-8)} · {new Date(e.ts).toLocaleDateString()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-[10px] text-white/30 leading-relaxed">
|
||||
Draw is via deterministic hash of the winning block header at the draw timestamp. Results posted in{" "}
|
||||
<Link href="/forum" className="text-white/50 hover:text-white underline">
|
||||
/forum
|
||||
</Link>
|
||||
. Prizes are LUX credits — no fiat or BTC payout. Entry fees are non-refundable.
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
68
app/red-room/page.tsx
Normal file
68
app/red-room/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
export default function RedRoomPage() {
|
||||
return (
|
||||
<ThemedLayout theme="dark" title="The Red Room">
|
||||
<div className="min-h-screen flex flex-col items-center justify-center text-red-600 font-mono p-8">
|
||||
<div className="max-w-3xl w-full border-4 border-red-900 p-12 bg-[#0a0000] shadow-[0_0_100px_rgba(255,0,0,0.2)]">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-6xl font-black tracking-tighter mb-4 animate-pulse">RED ROOM</h1>
|
||||
<p className="text-xs uppercase tracking-[1em] opacity-50">Live Stream</p>
|
||||
</div>
|
||||
|
||||
<div className="relative aspect-video bg-black border-2 border-red-900 flex items-center justify-center overflow-hidden mb-8">
|
||||
<div className="absolute top-4 left-4 flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-red-600 rounded-full animate-ping" />
|
||||
<span className="text-xs font-bold">LIVE: 12,402 VIEWERS</span>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 text-[10px] opacity-40">
|
||||
03:24:12:08
|
||||
</div>
|
||||
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-4xl opacity-20">NO SIGNAL</div>
|
||||
<div className="text-[10px] uppercase tracking-widest opacity-40">Waiting for next event...</div>
|
||||
</div>
|
||||
|
||||
{/* Static Overlay */}
|
||||
<div className="absolute inset-0 opacity-10 pointer-events-none bg-[url('https://media.giphy.com/media/oEI9uWUqnW3CeEnwL6/giphy.gif')] bg-cover" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8 mb-12">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold border-b border-red-900/40 pb-2">VOTING</h3>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span>OPTION A: LIGHTS ON</span>
|
||||
<span className="font-bold">42%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-red-900/20">
|
||||
<div className="h-full bg-red-600 w-[42%]" />
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span>OPTION B: LIGHTS OFF</span>
|
||||
<span className="font-bold">58%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-red-900/20">
|
||||
<div className="h-full bg-red-600 w-[58%]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold border-b border-red-900/40 pb-2">BETTING</h3>
|
||||
<div className="text-[10px] opacity-60 leading-relaxed">
|
||||
Place your bets in BTC to influence the outcome.
|
||||
Minimum bet: 0.005 BTC.
|
||||
</div>
|
||||
<button className="w-full bg-red-900/90 py-2 text-xs font-bold uppercase text-red-100 transition-all hover:bg-red-800">
|
||||
PLACE BET
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-red-900/40 leading-relaxed text-center italic">
|
||||
* Feed may drop or rotate without notice. Viewer discretion advised.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
228
app/reviews/page.tsx
Normal file
228
app/reviews/page.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
export default function ReviewsPage() {
|
||||
return (
|
||||
<ThemedLayout
|
||||
theme="editorial"
|
||||
siteTitle="DarkNet Review Blog"
|
||||
siteSubtitle="Independent analysis of underground marketplaces"
|
||||
>
|
||||
<section className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="theme-card rounded-2xl p-10 md:p-16">
|
||||
<div className="max-w-3xl">
|
||||
<span className="theme-accent rounded-full bg-opacity-20 px-4 py-2 text-sm font-bold">FEATURED REVIEW</span>
|
||||
<h1 className="mt-6 text-5xl font-bold md:text-6xl">
|
||||
CyberLux: The <span className="theme-accent">Gold Standard</span> of DarkNet Luxury
|
||||
</h1>
|
||||
<p className="mt-6 text-xl opacity-90">
|
||||
After six months of testing, our team concludes that CyberLux sets a new benchmark for security, user experience, and product quality in the underground e‑commerce space.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-wrap items-center gap-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-5xl font-bold">9.8</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">Overall Score</div>
|
||||
<div className="opacity-70">out of 10</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-12 w-px bg-current opacity-30"></div>
|
||||
<div>
|
||||
<div className="text-lg font-bold">VERDICT</div>
|
||||
<div className="theme-accent text-2xl font-bold">HIGHLY RECOMMENDED</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="grid grid-cols-1 gap-12 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<article className="prose prose-invert max-w-none">
|
||||
<h2>Executive Summary</h2>
|
||||
<p>
|
||||
CyberLux is not just another darknet marketplace—it is a meticulously crafted ecosystem that blends cutting‑edge cryptography with a luxury shopping experience. Unlike competitors that prioritize volume over quality, CyberLux curates a selection of high‑value items and wraps them in an interface that feels both futuristic and deeply secure.
|
||||
</p>
|
||||
<p>
|
||||
Our security audit revealed no leaks, no tracking, and no evidence of exit‑scam behavior. The platform’s use of client‑side encryption, multi‑signature escrow, and IPFS‑hosted content sets a new standard for operational security.
|
||||
</p>
|
||||
|
||||
<h2>Strengths</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Unmatched Privacy:</strong> Zero‑knowledge proofs ensure that even platform admins cannot access user data. Transaction metadata is stored on a private blockchain with optional integration into Monero for complete untraceability.
|
||||
</li>
|
||||
<li>
|
||||
<strong>UI/UX Excellence:</strong> The interface is intuitive despite its advanced features. Glass‑morphism, particle effects, and subtle animations create an immersive atmosphere without sacrificing performance.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Product Authenticity:</strong> Every item is verified by a network of trusted reviewers. We ordered seven products; all matched descriptions and arrived in discreet, tamper‑evident packaging.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Community Trust:</strong> With a 98% satisfaction rate across 2,400+ verified transactions, CyberLux boasts the highest trust‑to‑fraud ratio we have observed in five years of monitoring darknet markets.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Areas for Improvement</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Limited Product Range:</strong> The curated approach means fewer listings than mega‑markets. However, this is a deliberate trade‑off that appeals to discerning buyers.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Higher Price Points:</strong> Premium items command premium prices. Budget‑focused shoppers may find better deals elsewhere, albeit with higher risk.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>Security Deep Dive</h2>
|
||||
<p>
|
||||
We attempted to penetrate CyberLux’s encryption using a suite of modern tools (including quantum‑simulation attacks). The platform’s implementation of post‑quantum cryptographic primitives resisted all attempts. The “Vault” feature—a personal encrypted space for receipts and loyalty badges—is particularly impressive, leveraging Argon2id for key derivation and XChaCha20‑Poly1305 for authenticated encryption.
|
||||
</p>
|
||||
|
||||
<h2>Final Recommendation</h2>
|
||||
<p>
|
||||
For buyers who value discretion, quality, and a seamless experience, CyberLux is the undisputed leader. It is the only marketplace we are comfortable recommending to high‑net‑worth individuals and operational security‑conscious users. The platform’s commitment to innovation (e.g., experimental IPFS gateway, phantom‑mode navigation) suggests it will remain at the forefront for the foreseeable future.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="mt-12 theme-card rounded-2xl p-8">
|
||||
<h3 className="text-2xl font-bold">Review Metadata</h3>
|
||||
<div className="mt-6 grid grid-cols-2 gap-6 md:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-sm opacity-70">Testing Period</div>
|
||||
<div className="text-lg font-bold">180 days</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm opacity-70">Transactions Tested</div>
|
||||
<div className="text-lg font-bold">47</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm opacity-70">Security Score</div>
|
||||
<div className="text-lg font-bold theme-accent">10/10</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm opacity-70">Update Date</div>
|
||||
<div className="text-lg font-bold">2026‑03‑18</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-8">
|
||||
<div className="theme-card rounded-2xl p-8">
|
||||
<h3 className="text-2xl font-bold">Overall Rating</h3>
|
||||
<div className="mt-6 space-y-6">
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<span>Security</span>
|
||||
<span className="font-bold">10.0</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-black/10">
|
||||
<div className="h-full w-full rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<span>User Experience</span>
|
||||
<span className="font-bold">9.5</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-black/10">
|
||||
<div className="h-full w-9/10 rounded-full bg-blue-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<span>Product Quality</span>
|
||||
<span className="font-bold">9.8</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-black/10">
|
||||
<div className="h-full w-9/10 rounded-full bg-purple-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between">
|
||||
<span>Support</span>
|
||||
<span className="font-bold">9.2</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-black/10">
|
||||
<div className="h-full w-9/10 rounded-full bg-yellow-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-card rounded-2xl p-8">
|
||||
<h3 className="text-2xl font-bold">Comparison with Competitors</h3>
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex justify-between border-b border-current/20 pb-3">
|
||||
<span>CyberLux</span>
|
||||
<span className="font-bold theme-accent">9.8</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-current/20 pb-3">
|
||||
<span>ShadowBazaar</span>
|
||||
<span className="font-bold text-yellow-400">7.1</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-current/20 pb-3">
|
||||
<span>Phantom Market</span>
|
||||
<span className="font-bold text-red-400">5.4</span>
|
||||
</div>
|
||||
<div className="flex justify-between pb-3">
|
||||
<span>DeepNet Emporium</span>
|
||||
<span className="font-bold text-yellow-400">6.9</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-gradient-to-br from-blue-900/30 to-purple-900/30 p-8">
|
||||
<h3 className="text-2xl font-bold">Verified by Community</h3>
|
||||
<p className="mt-4 opacity-90">
|
||||
This review is based on data aggregated from 12 independent darknet‑trust platforms and 340 user testimonials.
|
||||
</p>
|
||||
<button className="mt-8 w-full rounded-full bg-blue-600 py-3 font-bold hover:bg-blue-700">
|
||||
Read User Testimonials
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-current/20 px-4 py-12">
|
||||
<div className="container mx-auto max-w-6xl">
|
||||
<div className="grid grid-cols-1 gap-10 md:grid-cols-3">
|
||||
<div>
|
||||
<h3 className="mb-4 text-xl font-bold">DarkNet Review Blog</h3>
|
||||
<p className="opacity-70">
|
||||
We provide unbiased, technical evaluations of darknet marketplaces. Our team consists of security researchers, data analysts, and veteran underground shoppers.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold">EDITORIAL</h3>
|
||||
<p className="text-sm opacity-60">
|
||||
Reviews reflect our methodology at publication time. Markets churn — verify signed canaries, escrow policy, and vendor PGP before you trust a link.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold">CONTACT</h3>
|
||||
<p className="opacity-70">
|
||||
Encrypted email: <span className="text-blue-400">reviews@darknetblog.example</span>
|
||||
</p>
|
||||
<div className="mt-6 flex gap-4">
|
||||
<button className="rounded-full bg-black/10 p-3 hover:opacity-80">🔒</button>
|
||||
<button className="rounded-full bg-black/10 p-3 hover:opacity-80">📧</button>
|
||||
<button className="rounded-full bg-black/10 p-3 hover:opacity-80">⚡</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 border-t border-current/20 pt-8 text-center text-sm opacity-60">
|
||||
<p>© 2026 DarkNet Review Blog · Independent onion mirror — verify PGP block</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
27
app/root-layout-client.tsx
Normal file
27
app/root-layout-client.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import SecretLayer from "@/components/SecretLayer";
|
||||
import OnionDisclosureBar from "@/components/OnionDisclosureBar";
|
||||
import HubChatterPublicStrip from "@/components/HubChatterPublicStrip";
|
||||
import AppProviders from "@/components/AppProviders";
|
||||
import DarkWebLaunchFooter from "@/components/DarkWebLaunchFooter";
|
||||
import RaffleCountdownBanner from "@/components/RaffleCountdownBanner";
|
||||
|
||||
export default function RootLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppProviders>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Raffle countdown sticky bar — visible on every page */}
|
||||
<RaffleCountdownBanner />
|
||||
<div className="flex-1">{children}</div>
|
||||
<DarkWebLaunchFooter />
|
||||
</div>
|
||||
{/* Must stay inside AppProviders — uses useAccount() */}
|
||||
<HubChatterPublicStrip />
|
||||
</AppProviders>
|
||||
<SecretLayer />
|
||||
<OnionDisclosureBar />
|
||||
</>
|
||||
);
|
||||
}
|
||||
288
app/sanctuary/page.tsx
Normal file
288
app/sanctuary/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
const BREATH_PHASES = [
|
||||
{ label: "Inhale", duration: 4, color: "#d4af37" },
|
||||
{ label: "Hold", duration: 4, color: "#a07820" },
|
||||
{ label: "Exhale", duration: 6, color: "#7a5c10" },
|
||||
{ label: "Rest", duration: 2, color: "#4a3c00" },
|
||||
];
|
||||
const TOTAL_CYCLE = BREATH_PHASES.reduce((s, p) => s + p.duration, 0);
|
||||
|
||||
const MEDITATIONS = [
|
||||
"The signal exists whether or not you observe it. Observe it.",
|
||||
"Your handle is not your identity. Your actions are.",
|
||||
"Anonymity is not invisibility — it is the deliberate curation of what is seen.",
|
||||
"Every encrypted packet is a sealed letter to yourself across time.",
|
||||
"You are one hop in a circuit that spans the planet.",
|
||||
"In the void between nodes, there is no latency. Only intention.",
|
||||
"Trust no one node. Trust the protocol.",
|
||||
"The archive remembers. The relay forgets. Choose which you are.",
|
||||
];
|
||||
|
||||
const CANDLE_KEY = "cyberlux-candles-v1";
|
||||
type CandleEntry = { ts: number; note?: string };
|
||||
|
||||
function loadCandles(): CandleEntry[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try { return JSON.parse(localStorage.getItem(CANDLE_KEY) ?? "[]") as CandleEntry[]; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function addCandle(note?: string) {
|
||||
const candles = loadCandles();
|
||||
candles.push({ ts: Date.now(), note });
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(CANDLE_KEY, JSON.stringify(candles.slice(-50)));
|
||||
}
|
||||
return candles.length + 1;
|
||||
}
|
||||
|
||||
export default function SanctuaryPage() {
|
||||
const [tab, setTab] = useState<"breathe" | "meditate" | "candle">("breathe");
|
||||
|
||||
// Breathing exercise
|
||||
const [breathing, setBreathing] = useState(false);
|
||||
const [phase, setPhase] = useState(0);
|
||||
const [phaseTime, setPhaseTime] = useState(0);
|
||||
const breathRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Meditation
|
||||
const [meditationIdx, setMeditationIdx] = useState(0);
|
||||
const [meditationSeconds, setMeditationSeconds] = useState(0);
|
||||
const [meditating, setMeditating] = useState(false);
|
||||
const medRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Candle
|
||||
const [candleNote, setCandleNote] = useState("");
|
||||
const [candleLit, setCandleLit] = useState(false);
|
||||
const [candleCount, setCandleCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCandleCount(loadCandles().length);
|
||||
}, []);
|
||||
|
||||
const startBreathing = useCallback(() => {
|
||||
setBreathing(true);
|
||||
setPhase(0);
|
||||
setPhaseTime(0);
|
||||
if (breathRef.current) clearInterval(breathRef.current);
|
||||
let elapsed = 0;
|
||||
breathRef.current = setInterval(() => {
|
||||
elapsed++;
|
||||
let acc = 0;
|
||||
for (let i = 0; i < BREATH_PHASES.length; i++) {
|
||||
acc += BREATH_PHASES[i]!.duration;
|
||||
if (elapsed % TOTAL_CYCLE < acc - (BREATH_PHASES[i]!.duration - 1) + (BREATH_PHASES[i]!.duration - 1)) {
|
||||
const cyclePos = elapsed % TOTAL_CYCLE;
|
||||
let phaseAcc = 0;
|
||||
for (let j = 0; j < BREATH_PHASES.length; j++) {
|
||||
if (cyclePos < phaseAcc + BREATH_PHASES[j]!.duration) {
|
||||
setPhase(j);
|
||||
setPhaseTime(cyclePos - phaseAcc);
|
||||
break;
|
||||
}
|
||||
phaseAcc += BREATH_PHASES[j]!.duration;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const stopBreathing = useCallback(() => {
|
||||
setBreathing(false);
|
||||
if (breathRef.current) clearInterval(breathRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => { if (breathRef.current) clearInterval(breathRef.current); }, []);
|
||||
|
||||
const startMeditation = useCallback(() => {
|
||||
setMeditating(true);
|
||||
setMeditationSeconds(0);
|
||||
setMeditationIdx(Math.floor(Math.random() * MEDITATIONS.length));
|
||||
if (medRef.current) clearInterval(medRef.current);
|
||||
medRef.current = setInterval(() => setMeditationSeconds((s) => s + 1), 1000);
|
||||
}, []);
|
||||
|
||||
const stopMeditation = useCallback(() => {
|
||||
setMeditating(false);
|
||||
if (medRef.current) clearInterval(medRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => { if (medRef.current) clearInterval(medRef.current); }, []);
|
||||
|
||||
const lightCandle = () => {
|
||||
const count = addCandle(candleNote.trim() || undefined);
|
||||
setCandleCount(count);
|
||||
setCandleLit(true);
|
||||
setCandleNote("");
|
||||
setTimeout(() => setCandleLit(false), 5000);
|
||||
};
|
||||
|
||||
const currentPhase = BREATH_PHASES[phase]!;
|
||||
const circleScale = (() => {
|
||||
if (!breathing) return 0.5;
|
||||
const progress = phaseTime / currentPhase.duration;
|
||||
if (phase === 0) return 0.5 + progress * 0.5;
|
||||
if (phase === 1) return 1;
|
||||
if (phase === 2) return 1 - progress * 0.5;
|
||||
return 0.5;
|
||||
})();
|
||||
|
||||
const formatMedSec = (s: number) =>
|
||||
`${Math.floor(s / 60).toString().padStart(2, "0")}:${(s % 60).toString().padStart(2, "0")}`;
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="mystic" title="The Sanctuary">
|
||||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||||
<h1 className="mb-2 font-serif text-4xl italic text-[#d4af37]">The Sanctuary</h1>
|
||||
<p className="mb-10 text-sm text-[#d4af37]/60">A place of quiet. No tracking. No noise.</p>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-8 flex justify-center gap-1 rounded-full bg-black/30 p-1 border border-[#d4af37]/15">
|
||||
{(["breathe", "meditate", "candle"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 rounded-full py-2 text-xs font-bold uppercase tracking-wider transition-all ${
|
||||
tab === t ? "bg-[#d4af37]/20 text-[#d4af37]" : "text-[#d4af37]/40 hover:text-[#d4af37]/70"
|
||||
}`}
|
||||
>
|
||||
{t === "breathe" ? "Breathing" : t === "meditate" ? "Meditation" : "Candles"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Breathing */}
|
||||
{tab === "breathe" && (
|
||||
<div className="space-y-8">
|
||||
<p className="text-sm text-[#d4af37]/70">
|
||||
4-4-6-2 breathing pattern. Calms the nervous system in under two minutes.
|
||||
</p>
|
||||
<div className="relative flex h-48 w-48 mx-auto items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full border-2 border-[#d4af37]/30 transition-transform duration-1000"
|
||||
style={{ transform: `scale(${circleScale})`, backgroundColor: `${currentPhase.color}18` }}
|
||||
/>
|
||||
<div className="relative text-center">
|
||||
<div className="text-2xl font-bold text-[#d4af37]">
|
||||
{breathing ? currentPhase.label : "Ready"}
|
||||
</div>
|
||||
{breathing && (
|
||||
<div className="text-sm text-[#d4af37]/60">
|
||||
{currentPhase.duration - phaseTime}s
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center gap-3">
|
||||
{BREATH_PHASES.map((p, i) => (
|
||||
<div
|
||||
key={p.label}
|
||||
className={`rounded-full px-3 py-1 text-xs font-bold uppercase transition-all ${
|
||||
breathing && phase === i
|
||||
? "bg-[#d4af37]/20 text-[#d4af37]"
|
||||
: "text-[#d4af37]/30"
|
||||
}`}
|
||||
>
|
||||
{p.label} {p.duration}s
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={breathing ? stopBreathing : startBreathing}
|
||||
className="rounded-full border-2 border-[#d4af37]/40 px-8 py-3 text-sm font-bold uppercase text-[#d4af37] hover:bg-[#d4af37]/10 transition-all"
|
||||
>
|
||||
{breathing ? "Stop" : "Begin Session"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meditation */}
|
||||
{tab === "meditate" && (
|
||||
<div className="space-y-8">
|
||||
<div className="min-h-[5rem] flex items-center justify-center">
|
||||
<p className="max-w-md text-xl italic leading-relaxed text-[#d4af37]/80">
|
||||
"{MEDITATIONS[meditationIdx]}"
|
||||
</p>
|
||||
</div>
|
||||
{meditating && (
|
||||
<div className="text-3xl font-mono text-[#d4af37]">{formatMedSec(meditationSeconds)}</div>
|
||||
)}
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={meditating ? stopMeditation : startMeditation}
|
||||
className="rounded-full border-2 border-[#d4af37]/40 px-8 py-3 text-sm font-bold uppercase text-[#d4af37] hover:bg-[#d4af37]/10 transition-all"
|
||||
>
|
||||
{meditating ? "End Session" : "Begin Meditation"}
|
||||
</button>
|
||||
{!meditating && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMeditationIdx((i) => (i + 1) % MEDITATIONS.length)}
|
||||
className="rounded-full border border-[#d4af37]/20 px-5 py-3 text-xs text-[#d4af37]/50 hover:text-[#d4af37]/80 transition-all"
|
||||
>
|
||||
Next passage
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!meditating && meditationSeconds > 0 && (
|
||||
<p className="text-sm text-[#d4af37]/50">
|
||||
Session: {formatMedSec(meditationSeconds)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candle */}
|
||||
{tab === "candle" && (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-[#d4af37]/60">
|
||||
Light a candle. Add an optional note. Stored only in this browser.{" "}
|
||||
<span className="text-[#d4af37]/40">{candleCount} lit so far.</span>
|
||||
</p>
|
||||
<div className="relative mx-auto flex h-32 w-12 flex-col items-center">
|
||||
<div
|
||||
className={`mb-1 h-8 w-2 rounded-full transition-all duration-700 ${
|
||||
candleLit ? "bg-amber-300 shadow-[0_0_20px_#fbbf24,0_0_40px_#fbbf24] animate-pulse" : "bg-[#d4af37]/20"
|
||||
}`}
|
||||
/>
|
||||
<div className="h-20 w-10 rounded-b bg-gradient-to-b from-[#d4af37]/30 to-[#d4af37]/10 border border-[#d4af37]/20" />
|
||||
</div>
|
||||
<textarea
|
||||
value={candleNote}
|
||||
onChange={(e) => setCandleNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
placeholder="Optional note for this candle… (stored locally, not sent anywhere)"
|
||||
className="w-full rounded-xl border border-[#d4af37]/20 bg-black/30 px-4 py-3 text-sm text-[#d4af37]/80 focus:border-[#d4af37]/40 focus:outline-none resize-none placeholder:text-[#d4af37]/30"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={lightCandle}
|
||||
className="rounded-full border-2 border-[#d4af37]/40 px-8 py-3 text-sm font-bold uppercase text-[#d4af37] hover:bg-[#d4af37]/10 transition-all"
|
||||
>
|
||||
{candleLit ? "✦ Candle Lit" : "Light a Candle"}
|
||||
</button>
|
||||
{candleLit && (
|
||||
<p className="text-sm text-[#d4af37]/60">Candle #{candleCount} lit. It burns in this browser.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-16 flex justify-center gap-6 text-xs text-[#d4af37]/30">
|
||||
<Link href="/" className="hover:text-[#d4af37]/60">Hub</Link>
|
||||
<Link href="/vault" className="hover:text-[#d4af37]/60">Vault</Link>
|
||||
<Link href="/mixer" className="hover:text-[#d4af37]/60">Mixer</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
217
app/search/page.tsx
Normal file
217
app/search/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import {
|
||||
CYBERLUX_SEARCH_INDEX_SIZE,
|
||||
searchCyberluxIndex,
|
||||
searchZoneLabel,
|
||||
VOID_LEXICON_WORDS,
|
||||
type CyberluxSearchDoc,
|
||||
} from "@/lib/cyberluxSearchIndex";
|
||||
|
||||
const LEXICON_FOG_SAMPLE = VOID_LEXICON_WORDS.slice(0, 96);
|
||||
|
||||
function keywordSample(doc: CyberluxSearchDoc, limit = 20): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const w of doc.keywords.split(/\s+/)) {
|
||||
const t = w.trim().toLowerCase();
|
||||
if (t.length < 3 || seen.has(t)) continue;
|
||||
seen.add(t);
|
||||
out.push(t);
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** v3-style hostnames — one per deployment node; paths are in-app routes. */
|
||||
const ZONE_ONION_HOST: Record<CyberluxSearchDoc["zone"], string> = {
|
||||
hub: "clx7hubz9vmkq2wyn4pj3lrtyx8yd6x3k8fnplwj6tor.onion",
|
||||
forum: "voidagg8n3kx2wz7m4pj6lrtyx8yd5xq9kfvnplwjtor.onion",
|
||||
exchange: "clxexch4kz8vm7nq2wyn9pj3lrtyx8yd2x6kfnplwj.onion",
|
||||
wiki: "clxwiki3jq8vmkq2wyn4pj6lrtyx8yd7x2k9fmwnplwj.onion",
|
||||
syndicate: "clxsyn7kq2wz8vm4n9pj3lrtyx8yd4x3k6fnplwjtor.onion",
|
||||
account: "clxacct9vmkq2wz8n4pj6lrtyx8yd1x7k3fnplwjtor.onion",
|
||||
};
|
||||
|
||||
function onionDisplayLine(doc: CyberluxSearchDoc): string {
|
||||
const host = ZONE_ONION_HOST[doc.zone];
|
||||
const p = doc.path === "/" ? "" : doc.path;
|
||||
return `http://${host}${p}`;
|
||||
}
|
||||
|
||||
export default function VoidCrawlerSearchPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const results = useMemo(() => {
|
||||
if (!submitted.trim()) return [];
|
||||
return searchCyberluxIndex(submitted);
|
||||
}, [submitted]);
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
setBusy(true);
|
||||
window.setTimeout(() => {
|
||||
setSubmitted(q);
|
||||
setBusy(false);
|
||||
}, 320);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#030806] text-[#c8e6d0]">
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0 opacity-[0.12]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,255,65,0.06) 3px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<header className="relative z-10 border-b border-[#00ff4122] bg-[#020504]/95 px-4 py-10 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.45em] text-[#00ff41]/55">closed corpus · internal index</p>
|
||||
<h1 className="mt-3 font-mono text-4xl font-black tracking-tight text-white md:text-5xl">
|
||||
VOID<span className="text-[#00ff41]">CRAWLER</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-xl font-mono text-xs leading-relaxed text-[#6b9b78]">
|
||||
Crawls <strong className="text-[#9bffb8]">{CYBERLUX_SEARCH_INDEX_SIZE}</strong> signed routes on{" "}
|
||||
<strong className="text-[#9bffb8]">this</strong> ring only — hub, forum, exchange, wiki, syndicate shells, and account surfaces.
|
||||
Foreign onions and clearnet bridges are deliberately excluded from the corpus.
|
||||
</p>
|
||||
<div className="mx-auto mt-8 max-w-4xl border border-[#00ff4120] bg-[#040a06]/80 px-3 py-3 text-left">
|
||||
<p className="mb-2 font-mono text-[9px] uppercase tracking-[0.4em] text-[#00ff41]/40">corpus lexicon fog · sample tokens</p>
|
||||
<div className="flex flex-wrap justify-center gap-1">
|
||||
{LEXICON_FOG_SAMPLE.map((w) => (
|
||||
<span
|
||||
key={w}
|
||||
className="rounded border border-[#00ff41]/12 px-1 py-0.5 font-mono text-[8px] lowercase text-[#5a8f6a]"
|
||||
>
|
||||
{w}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-10 mx-auto max-w-3xl px-4 pb-32 pt-10">
|
||||
<form onSubmit={onSubmit} className="relative">
|
||||
<label htmlFor="void-query" className="sr-only">
|
||||
Search CyberLux sites
|
||||
</label>
|
||||
<input
|
||||
id="void-query"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="e.g. forum barter vault checkout atlas…"
|
||||
className="w-full border-2 border-[#00ff4133] bg-black/80 py-4 pl-4 pr-28 font-mono text-sm text-[#00ff41] placeholder:text-[#00ff41]/25 focus:border-[#00ff41] focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !query.trim()}
|
||||
className="absolute right-1 top-1 bottom-1 bg-[#00ff41] px-5 font-mono text-xs font-black uppercase text-black hover:bg-[#00cc33] disabled:opacity-40"
|
||||
>
|
||||
{busy ? "…" : "Crawl"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 font-mono text-[10px] uppercase tracking-widest text-[#4a6b55]">
|
||||
Tip: multi-word queries match <span className="text-[#6b9b78]">all</span> tokens (AND).
|
||||
</p>
|
||||
|
||||
<div className="mt-10 space-y-6">
|
||||
{busy ? (
|
||||
<p className="animate-pulse text-center font-mono text-xs uppercase tracking-[0.3em] text-[#00ff41]/45">
|
||||
Walking local routes…
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!busy && submitted && (
|
||||
<p className="font-mono text-xs text-[#6b9b78]">
|
||||
<span className="text-[#00ff41]">{results.length}</span> hit{results.length === 1 ? "" : "s"} for{" "}
|
||||
<span className="text-[#c8e6d0]">{submitted}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{results.map((doc) => (
|
||||
<article
|
||||
key={doc.path}
|
||||
className="border-l-4 border-[#00ff4140] bg-[#050c08]/90 py-4 pl-5 pr-4 shadow-[inset_0_0_0_1px_rgba(0,255,65,0.06)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded border border-[#00ff4133] px-2 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[#7af598]">
|
||||
{searchZoneLabel(doc.zone)}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 font-mono text-lg font-bold">
|
||||
<Link href={doc.path} className="text-[#7af598] hover:text-[#b4ffcc] hover:underline">
|
||||
{doc.title}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="mt-1 break-all font-mono text-[10px] leading-relaxed text-[#00ff41]/35">
|
||||
{onionDisplayLine(doc)}
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-xs leading-relaxed text-[#8eb89a]/95">{doc.description}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{keywordSample(doc).map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="rounded bg-[#00ff41]/08 px-1.5 py-0.5 font-mono text-[8px] lowercase text-[#6b9b78]"
|
||||
>
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[10px] text-[#4a6b55]">
|
||||
Route:{" "}
|
||||
<Link href={doc.path} className="text-[#6b9b78] hover:text-[#00ff41]">
|
||||
{doc.path}
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{!busy && submitted && results.length === 0 ? (
|
||||
<div className="border-2 border-dashed border-[#00ff4122] py-16 text-center">
|
||||
<p className="font-mono text-sm text-[#6b9b78]">No in-app routes match that query.</p>
|
||||
<p className="mt-3 font-mono text-[11px] text-[#4a6b55]">
|
||||
Try <button type="button" className="text-[#00ff41] underline" onClick={() => setQuery("forum")}>forum</button>,{" "}
|
||||
<button type="button" className="text-[#00ff41] underline" onClick={() => setQuery("checkout")}>
|
||||
checkout
|
||||
</button>
|
||||
, or <button type="button" className="text-[#00ff41] underline" onClick={() => setQuery("atlas")}>atlas</button>.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="mt-16 flex flex-wrap justify-center gap-4 border-t border-[#00ff4118] pt-10 font-mono text-[11px]">
|
||||
<Link href="/" className="text-[#6b9b78] hover:text-[#00ff41]">
|
||||
← Hub
|
||||
</Link>
|
||||
<Link href="/forum" className="text-[#6b9b78] hover:text-[#00ff41]">
|
||||
Forum
|
||||
</Link>
|
||||
<Link href="/hidden-wiki" className="text-[#6b9b78] hover:text-[#00ff41]">
|
||||
Wiki
|
||||
</Link>
|
||||
<Link href="/darknet-atlas" className="text-[#6b9b78] hover:text-[#00ff41]">
|
||||
Atlas
|
||||
</Link>
|
||||
<Link href="/links" className="text-[#6b9b78] hover:text-[#00ff41]">
|
||||
Link garden
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<p className="mt-10 text-center font-mono text-[9px] uppercase tracking-[0.25em] text-[#00ff41]/20">
|
||||
void crawler v1 · {CYBERLUX_SEARCH_INDEX_SIZE} documents · internal index only
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
app/secret-layer/page.tsx
Normal file
40
app/secret-layer/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
|
||||
export default function SecretLayerPage() {
|
||||
const [binary, setBinary] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
let str = "";
|
||||
for(let i=0; i<800; i++) {
|
||||
str += Math.random() > 0.5 ? "1" : "0";
|
||||
}
|
||||
setBinary(str);
|
||||
}, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="terminal" siteTitle="LEVEL 2" siteSubtitle="Clearance required">
|
||||
<div className="fixed inset-0 overflow-hidden opacity-20 theme-accent break-all font-mono text-xs leading-none z-0 pointer-events-none">
|
||||
{binary}
|
||||
</div>
|
||||
<section className="relative z-10 container mx-auto max-w-4xl px-4 py-12 text-center min-h-[70vh] flex flex-col justify-center items-center">
|
||||
<h1 className="text-4xl font-bold text-red-500 mb-8 animate-pulse">
|
||||
LEVEL 2 CLEARANCE REQUIRED
|
||||
</h1>
|
||||
<p className="mb-8 text-xl theme-card p-6 rounded-xl border-2 border-red-500">
|
||||
You are not supposed to be here. The cyber-cops have been dispatched.
|
||||
They are bringing donuts.
|
||||
</p>
|
||||
<Link href="/" className="bg-red-500 text-white px-8 py-4 rounded-full font-bold hover:bg-red-600 transition">
|
||||
FLEE TO MAIN SITE
|
||||
</Link>
|
||||
</section>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
168
app/security-analysis/page.tsx
Normal file
168
app/security-analysis/page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const STACK = [
|
||||
{
|
||||
layer: "Account Storage",
|
||||
what: "All accounts are stored in browser localStorage under cyberlux-accounts-v1.",
|
||||
how: "Passwords are hashed SHA-256 with a static pepper before storage. No server ever receives your password.",
|
||||
honest: "SHA-256 with a static pepper is not as strong as Argon2/bcrypt. Suitable for a local-first demo — not a production secret store.",
|
||||
grade: "B",
|
||||
},
|
||||
{
|
||||
layer: "Session Management",
|
||||
what: "Sessions are stored in localStorage as the current username after password verification.",
|
||||
how: "No JWT or server-side token. Session is simply the username string persisted until sign-out.",
|
||||
honest: "There is no token expiry or rotation. If someone has physical access to your browser they can read the session. Use private/incognito mode for isolation.",
|
||||
grade: "C+",
|
||||
},
|
||||
{
|
||||
layer: "Per-Account Ledger",
|
||||
what: "USD balance, LUX credits, and claimed BTC txids are keyed by username in localStorage.",
|
||||
how: "On each handle change, legacy device-wide balances are migrated into the keyed structure. Ledger key: cyberlux-account-ledger-v1.",
|
||||
honest: "Data is client-side only. Clearing localStorage wipes balances. Export your account bundle from /dashboard to back up.",
|
||||
grade: "A-",
|
||||
},
|
||||
{
|
||||
layer: "Bitcoin Deposit Verification",
|
||||
what: "Deposits are verified server-side against mempool.space (on-chain) and CoinGecko (BTC/USD rate).",
|
||||
how: "The /api/btc/verify route requires a txid, checks for ≥1 confirmation, verifies payment to MERCHANT_BTC_ADDRESS, and applies USD credit on success.",
|
||||
honest: "Requires MERCHANT_BTC_ADDRESS env var configured by the operator. Without it, the endpoint returns a configuration error. One-confirmation threshold — standard.",
|
||||
grade: "A",
|
||||
},
|
||||
{
|
||||
layer: "Vault / Receipt Store",
|
||||
what: "Vault is per-handle (cyberlux:vault:v2:<username>). Stores keys, receipts, and flags.",
|
||||
how: "Receipts are written on checkout and mixer actions. Migration from legacy v1 (device-wide) runs once on first sign-in.",
|
||||
honest: "Contents are plaintext JSON in localStorage. Not encrypted at rest in the browser — same trust model as any client-side state.",
|
||||
grade: "B+",
|
||||
},
|
||||
{
|
||||
layer: "Network Architecture",
|
||||
what: "Next.js app deployable behind Tor hidden services via Nginx loopbacks.",
|
||||
how: "Each onion address is a separate virtual host routed to the local Next.js process. Configured via systemd + nginx conf blocks documented in DEPLOY.md.",
|
||||
honest: "Network-layer privacy depends entirely on the operator's Tor and server configuration. The app itself does not configure Tor — see DEPLOY.md.",
|
||||
grade: "Operator-dependent",
|
||||
},
|
||||
];
|
||||
|
||||
const GRADE_COLOR: Record<string, string> = {
|
||||
"A": "bg-green-900/30 text-green-400 border-green-800",
|
||||
"A-": "bg-green-900/20 text-green-400 border-green-800",
|
||||
"B+": "bg-emerald-900/20 text-emerald-400 border-emerald-800",
|
||||
"B": "bg-cyan-900/20 text-cyan-400 border-cyan-800",
|
||||
"C+": "bg-yellow-900/20 text-yellow-400 border-yellow-800",
|
||||
"Operator-dependent": "bg-gray-800 text-gray-400 border-gray-700",
|
||||
};
|
||||
|
||||
export default function SecurityAnalysisPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-950 to-black text-gray-100">
|
||||
<header className="border-b border-gray-800">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-gradient-to-r from-red-500 to-orange-600" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Architecture Breakdown</h1>
|
||||
<p className="text-xs text-gray-400">Real technical analysis of the CyberLux stack</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<Link href="#stack" className="text-gray-300 hover:text-red-400">Stack</Link>
|
||||
<Link href="#opsec" className="text-gray-300 hover:text-red-400">OPSEC Guide</Link>
|
||||
<Link href="/" className="rounded-full bg-red-600 px-5 py-2 font-bold hover:bg-red-700">Hub</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 py-12">
|
||||
<div className="rounded-2xl border border-amber-800/40 bg-amber-900/10 p-6 mb-10">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">⚠️</span>
|
||||
<div>
|
||||
<div className="font-bold text-amber-300 mb-1">Honest Architecture Document</div>
|
||||
<p className="text-sm text-amber-200/70">
|
||||
This is a real technical breakdown of how CyberLux actually works — not a marketing audit. Grades
|
||||
reflect the actual security posture of each layer. Where limitations exist, they are explicitly
|
||||
noted. Read DEPLOY.md for operator-level configuration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-gradient-to-r from-gray-900 to-gray-800 p-10">
|
||||
<span className="rounded-full bg-red-900/30 px-3 py-1 text-xs font-bold text-red-300">TECHNICAL BREAKDOWN</span>
|
||||
<h1 className="mt-5 text-4xl font-bold md:text-5xl">CyberLux Architecture</h1>
|
||||
<p className="mt-5 max-w-3xl text-gray-300">
|
||||
Layer-by-layer analysis of the actual security model: account storage, session management, Bitcoin
|
||||
verification, vault, and network architecture. Honest grades included.
|
||||
</p>
|
||||
<div className="mt-8 grid grid-cols-3 gap-6 md:grid-cols-6">
|
||||
{STACK.map((s) => (
|
||||
<div key={s.layer} className={`rounded-lg border px-3 py-2 text-center text-xs font-bold ${GRADE_COLOR[s.grade] ?? "bg-gray-800 text-gray-400 border-gray-700"}`}>
|
||||
{s.grade}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="stack" className="container mx-auto max-w-6xl px-4 pb-16 space-y-6">
|
||||
<h2 className="text-2xl font-bold">Layer Analysis</h2>
|
||||
{STACK.map((s) => (
|
||||
<div key={s.layer} className="rounded-2xl border border-gray-800 bg-gray-900/50 p-8">
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">{s.layer}</h3>
|
||||
<p className="mt-2 text-gray-300">{s.what}</p>
|
||||
</div>
|
||||
<span className={`rounded-lg border px-4 py-2 text-sm font-bold ${GRADE_COLOR[s.grade] ?? "bg-gray-800 text-gray-400 border-gray-700"}`}>
|
||||
{s.grade}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-4 rounded-lg bg-gray-800/50 p-4 text-sm text-gray-300">
|
||||
<span className="text-green-400 font-bold">How it works: </span>{s.how}
|
||||
</div>
|
||||
<div className="rounded-lg bg-amber-900/20 border border-amber-800/30 p-4 text-sm text-amber-200/80">
|
||||
<span className="text-amber-300 font-bold">Honest assessment: </span>{s.honest}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section id="opsec" className="container mx-auto max-w-6xl px-4 pb-20">
|
||||
<div className="rounded-2xl border border-gray-800 bg-gray-900/30 p-10">
|
||||
<h2 className="mb-6 text-2xl font-bold">Practical OPSEC Guide</h2>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{[
|
||||
{ title: "Use Tor Browser on .onion deployments", body: "Safari and Chrome cannot resolve .onion hostnames. Tor Browser is required for accessing the onion version of this site." },
|
||||
{ title: "Export your account bundle", body: "From /dashboard, export your portable identity bundle before clearing browser data or switching devices." },
|
||||
{ title: "Verify the BTC address each session", body: "Before sending any Bitcoin, confirm the merchant address matches the one you used last session. Phishing clones will substitute their own address." },
|
||||
{ title: "localStorage is not encrypted at rest", body: "Your browser's localStorage is readable by JavaScript from the same origin. Do not store high-value secrets here beyond what the app requires." },
|
||||
{ title: "One-confirmation BTC threshold", body: "The deposit system credits after 1 confirmation — fast but not fully final. For large amounts, wait for 6 confirmations before spending the credited USD." },
|
||||
{ title: "Operator must configure .env", body: "MERCHANT_BTC_ADDRESS and optional NEXT_PUBLIC_BITCOIN_CHECKOUT_URL must be set in .env.local. Without these, Bitcoin verify returns a config error." },
|
||||
].map((item) => (
|
||||
<div key={item.title} className="rounded-xl border border-gray-800 p-5">
|
||||
<div className="mb-2 font-bold text-red-300">{item.title}</div>
|
||||
<p className="text-sm text-gray-400">{item.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/arb-academy" className="text-sm text-red-400 hover:underline">ARB Academy for more →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="border-t border-gray-800 px-4 py-8 text-center text-sm text-gray-600">
|
||||
<p>CyberLux Architecture Breakdown · Honest technical documentation · No fabricated audit results</p>
|
||||
<div className="mt-2 flex justify-center gap-6">
|
||||
<Link href="/" className="hover:text-gray-300">Hub</Link>
|
||||
<Link href="/trust" className="hover:text-gray-300">Trust Dashboard</Link>
|
||||
<Link href="/reviews" className="hover:text-gray-300">Reviews</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
app/sign-in/page.tsx
Normal file
129
app/sign-in/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
|
||||
function SignInContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, hydrated, signIn } = useAccount();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !user) return;
|
||||
const next = searchParams.get("next");
|
||||
router.replace(next && next.startsWith("/") ? next : "/dashboard");
|
||||
}, [hydrated, user, router, searchParams]);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
const res = await signIn(username, password);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
const next = searchParams.get("next");
|
||||
router.push(next && next.startsWith("/") ? next : "/dashboard");
|
||||
};
|
||||
|
||||
if (!hydrated || user) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center font-mono text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] px-4 py-16 text-zinc-200">
|
||||
<div className="mx-auto max-w-md">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-neon-cyan/80">session</p>
|
||||
<h1 className="mt-3 font-orbitron text-3xl font-bold">Sign in</h1>
|
||||
<p className="mt-3 text-sm text-zinc-500">
|
||||
Client-side vault unlock — your handle ties together forum posts, listings, and checkout history on{" "}
|
||||
<span className="text-zinc-400">this device</span>. Use a unique passphrase; staff never log access to
|
||||
your secrets.
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-zinc-500">
|
||||
Using a different .onion host?{" "}
|
||||
<Link href="/account/hidden-services" className="text-neon-cyan hover:underline">
|
||||
Register or import on every mirror
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<form onSubmit={onSubmit} className="glass mt-10 space-y-5 rounded-2xl border border-white/10 p-8">
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</p>
|
||||
) : null}
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">Handle</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm"
|
||||
placeholder="your_alias"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">Passphrase</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-neon-cyan to-neon-purple py-3 font-bold text-background disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Unlocking…" : "Unlock dashboard"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 text-center text-sm text-zinc-500">
|
||||
Need an identity?{" "}
|
||||
<Link
|
||||
href={searchParams.get("next") ? `/sign-up?next=${encodeURIComponent(searchParams.get("next")!)}` : "/sign-up"}
|
||||
className="text-neon-cyan hover:underline"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mt-6 text-center">
|
||||
<Link href="/" className="text-xs text-zinc-600 hover:text-zinc-400">
|
||||
← Hub
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[50vh] items-center justify-center font-mono text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignInContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
143
app/sign-up/page.tsx
Normal file
143
app/sign-up/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, Suspense, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
|
||||
function SignUpContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, hydrated, signUp } = useAccount();
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || !user) return;
|
||||
const next = searchParams.get("next");
|
||||
router.replace(next && next.startsWith("/") && !next.startsWith("//") ? next : "/dashboard");
|
||||
}, [hydrated, user, router, searchParams]);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
const res = await signUp(username, password, displayName || undefined);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.error);
|
||||
return;
|
||||
}
|
||||
const next = searchParams.get("next");
|
||||
router.push(next && next.startsWith("/") && !next.startsWith("//") ? next : "/dashboard");
|
||||
};
|
||||
|
||||
if (!hydrated || user) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center font-mono text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0a0a0a] px-4 py-16 text-zinc-200">
|
||||
<div className="mx-auto max-w-md">
|
||||
<p className="font-mono text-[10px] uppercase tracking-[0.35em] text-neon-purple/80">provision</p>
|
||||
<h1 className="mt-3 font-orbitron text-3xl font-bold">Create account</h1>
|
||||
<p className="mt-3 text-sm text-zinc-500">
|
||||
Provisioning writes credentials only to this browser profile. Match your{" "}
|
||||
<strong className="text-zinc-400">display name</strong> to forum signatures so reputation and escrow
|
||||
threads resolve cleanly.
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-zinc-500">
|
||||
Separate forum / exchange / wiki onions each need their own copy:{" "}
|
||||
<Link href="/account/hidden-services" className="text-neon-cyan hover:underline">
|
||||
Hidden services account guide
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<form onSubmit={onSubmit} className="glass mt-10 space-y-5 rounded-2xl border border-white/10 p-8">
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-2 text-sm text-red-300">{error}</p>
|
||||
) : null}
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">Handle (unique, login id)</label>
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm"
|
||||
placeholder="ledger_rat"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">Display name (shown on posts)</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm"
|
||||
placeholder="Defaults to handle if empty"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-zinc-400">Passphrase (8+ characters)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
className="glass w-full rounded-xl border border-white/10 bg-transparent px-4 py-3 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-neon-purple to-neon-pink py-3 font-bold text-white disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Provisioning…" : "Create & enter"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-8 text-center text-sm text-zinc-500">
|
||||
Already have a handle?{" "}
|
||||
<Link
|
||||
href={
|
||||
searchParams.get("next")
|
||||
? `/sign-in?next=${encodeURIComponent(searchParams.get("next")!)}`
|
||||
: "/sign-in"
|
||||
}
|
||||
className="text-neon-cyan hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
<p className="mt-6 text-center">
|
||||
<Link href="/" className="text-xs text-zinc-600 hover:text-zinc-400">
|
||||
← Hub
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-[50vh] items-center justify-center font-mono text-sm text-zinc-500">
|
||||
Loading…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SignUpContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
156
app/support/page.tsx
Normal file
156
app/support/page.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import ThemedLayout from "@/components/layouts/ThemedLayout";
|
||||
import { isMerchantBtcConfigured, getMerchantBtcAddress } from "@/lib/merchantBtc";
|
||||
|
||||
type Coin = "BTC" | "ETH" | "XMR";
|
||||
|
||||
function getAddresses() {
|
||||
return {
|
||||
BTC: (process.env.NEXT_PUBLIC_MERCHANT_BTC_ADDRESS || "").trim() || "bc1q — set NEXT_PUBLIC_MERCHANT_BTC_ADDRESS",
|
||||
ETH: (process.env.NEXT_PUBLIC_ETH_ADDRESS || "").trim() || "0x — set NEXT_PUBLIC_ETH_ADDRESS",
|
||||
XMR: (process.env.NEXT_PUBLIC_XMR_ADDRESS || "").trim() || "4A — set NEXT_PUBLIC_XMR_ADDRESS",
|
||||
};
|
||||
}
|
||||
|
||||
export default function SupportPage() {
|
||||
const [copied, setCopied] = useState<Record<Coin, boolean>>({ BTC: false, ETH: false, XMR: false });
|
||||
const btcReady = isMerchantBtcConfigured();
|
||||
const btcAddr = getMerchantBtcAddress();
|
||||
|
||||
const addresses = useMemo(() => getAddresses(), []);
|
||||
|
||||
const copy = async (coin: Coin) => {
|
||||
const addr = coin === "BTC" && btcReady ? btcAddr : addresses[coin];
|
||||
try {
|
||||
await navigator.clipboard.writeText(addr);
|
||||
setCopied((p) => ({ ...p, [coin]: true }));
|
||||
window.setTimeout(() => setCopied((p) => ({ ...p, [coin]: false })), 1800);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const displayAddr = (coin: Coin) =>
|
||||
coin === "BTC" && btcReady ? btcAddr : addresses[coin];
|
||||
|
||||
const configured = {
|
||||
BTC: btcReady,
|
||||
ETH: Boolean((process.env.NEXT_PUBLIC_ETH_ADDRESS || "").trim()),
|
||||
XMR: Boolean((process.env.NEXT_PUBLIC_XMR_ADDRESS || "").trim()),
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedLayout theme="editorial" siteTitle="THE OFFERING" siteSubtitle="Optional support · transparent tip jar">
|
||||
<section className="container mx-auto max-w-5xl px-4 py-12">
|
||||
<div className="theme-card rounded-3xl p-10 md:p-14">
|
||||
<div className="text-center">
|
||||
<div className="mb-3 text-[10px] uppercase tracking-widest opacity-70">optional ritual</div>
|
||||
<h1 className="text-4xl font-bold md:text-5xl">
|
||||
THE <span className="theme-accent">OFFERING</span>
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-3xl text-lg opacity-80">
|
||||
If this operation kept you online, throw fuel on the fire. Infrastructure and onion mirrors cost time and
|
||||
hardware.
|
||||
</p>
|
||||
<p className="mx-auto mt-3 max-w-3xl text-sm opacity-60">
|
||||
Transparent tip jar — choose your asset and amount. No paywalls, no pressure, no analytics. To
|
||||
configure addresses, set the corresponding env vars in{" "}
|
||||
<code className="rounded bg-white/10 px-1">.env.local</code> and restart Next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{(["BTC", "ETH", "XMR"] as Coin[]).map((coin) => {
|
||||
const addr = displayAddr(coin);
|
||||
const isConfigured = configured[coin];
|
||||
return (
|
||||
<div key={coin} className="theme-card rounded-2xl p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="text-2xl font-bold theme-accent">{coin}</div>
|
||||
{!isConfigured && (
|
||||
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-[10px] text-amber-300">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 min-h-[3.5rem] rounded-xl bg-black/40 p-3 font-mono text-xs theme-accent break-all">
|
||||
{isConfigured ? addr : (
|
||||
<span className="opacity-40">
|
||||
Set{" "}
|
||||
<code>
|
||||
NEXT_PUBLIC_{coin === "BTC" ? "MERCHANT_BTC" : coin}_ADDRESS
|
||||
</code>{" "}
|
||||
in .env.local
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`mt-4 w-full rounded-full border-2 border-current px-5 py-3 text-sm font-bold transition-all ${
|
||||
isConfigured
|
||||
? "theme-accent bg-current text-white hover:opacity-90"
|
||||
: "opacity-40 cursor-not-allowed"
|
||||
}`}
|
||||
onClick={() => void copy(coin)}
|
||||
disabled={!isConfigured}
|
||||
>
|
||||
{copied[coin] ? "✓ COPIED" : `COPY ${coin} ADDRESS`}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="theme-card rounded-2xl p-6">
|
||||
<div className="mb-3 font-bold">What you're supporting</div>
|
||||
<ul className="space-y-2 text-sm text-foreground/70">
|
||||
<li className="flex items-start gap-2"><span>—</span> Onion mirror hosting and key backup infrastructure</li>
|
||||
<li className="flex items-start gap-2"><span>—</span> More Vault unlocks, drops, and forum rings</li>
|
||||
<li className="flex items-start gap-2"><span>—</span> More UI weirdness (the good kind)</li>
|
||||
<li className="flex items-start gap-2"><span>—</span> The README staying accurate</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="theme-card rounded-2xl p-6">
|
||||
<div className="mb-3 font-bold">Where to go next</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/account/add-funds"
|
||||
className="rounded-full border border-neon-green/40 px-5 py-2 text-sm font-bold text-neon-green hover:bg-neon-green/10"
|
||||
>
|
||||
Add funds →
|
||||
</Link>
|
||||
<Link
|
||||
href="/market"
|
||||
className="rounded-full border border-white/20 px-5 py-2 text-sm font-bold hover:border-neon-cyan/40"
|
||||
>
|
||||
Market
|
||||
</Link>
|
||||
<Link
|
||||
href="/forum"
|
||||
className="rounded-full border border-white/20 px-5 py-2 text-sm font-bold hover:border-neon-cyan/40"
|
||||
>
|
||||
Forum
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full bg-neon-cyan/15 border border-neon-cyan/30 px-5 py-2 text-sm font-bold text-neon-cyan hover:bg-neon-cyan/20"
|
||||
>
|
||||
Hub →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 rounded-xl bg-white/5 p-4 text-center text-xs text-foreground/50">
|
||||
Confirm any BTC address against the signed message on the hub before sending. Same bytes, every time.
|
||||
If the address differs from a prior session, treat it as a compromise — do not proceed.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</ThemedLayout>
|
||||
);
|
||||
}
|
||||
5
app/syndicate/layout.tsx
Normal file
5
app/syndicate/layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SyndicateSiteChrome from "@/components/syndicate/SyndicateSiteChrome";
|
||||
|
||||
export default function SyndicateLayout({ children }: { children: React.ReactNode }) {
|
||||
return <SyndicateSiteChrome>{children}</SyndicateSiteChrome>;
|
||||
}
|
||||
33
app/syndicate/page.tsx
Normal file
33
app/syndicate/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { SYNDICATE_NODES } from "@/lib/syndicateNetwork";
|
||||
|
||||
/** Index of themed `/w/[slug]` nodes — same Next app, different chrome. */
|
||||
export default function SyndicateIndexPage() {
|
||||
return (
|
||||
<main>
|
||||
<p className="mb-8 text-sm text-zinc-400">
|
||||
Deep links:{" "}
|
||||
<Link href="/syndicate/topology" className="text-fuchsia-400 underline hover:text-fuchsia-300">
|
||||
topology
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/syndicate/routing" className="text-fuchsia-400 underline hover:text-fuchsia-300">
|
||||
routing lore
|
||||
</Link>
|
||||
</p>
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{SYNDICATE_NODES.map((n) => (
|
||||
<li key={n.slug}>
|
||||
<Link
|
||||
href={`/w/${n.slug}`}
|
||||
className="block rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 transition hover:border-fuchsia-500/40 hover:bg-zinc-900"
|
||||
>
|
||||
<span className="font-mono text-sm text-fuchsia-300">/{n.slug}</span>
|
||||
<p className="mt-1 text-xs text-zinc-500">{n.tagline}</p>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
34
app/syndicate/routing/page.tsx
Normal file
34
app/syndicate/routing/page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SyndicateRoutingPage() {
|
||||
return (
|
||||
<article className="space-y-8">
|
||||
<header>
|
||||
<h2 className="text-xl font-bold text-white md:text-2xl">Routing lore</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm text-zinc-400">
|
||||
Dedicated onions rewrite paths to the correct vertical so bookmarks survive. This page names the behavior operators describe on status boards — it mirrors how <code className="text-zinc-500">proxy.ts</code> reasons about roots, without dumping source.
|
||||
</p>
|
||||
</header>
|
||||
<ul className="space-y-4 text-sm text-zinc-300">
|
||||
<li className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<strong className="text-fuchsia-200">Single build</strong> — every hidden service serves the same compiled routes; differentiation is host-aware rewrites and disclosure chrome, not separate deploy artifacts per onion.
|
||||
</li>
|
||||
<li className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<strong className="text-fuchsia-200">Canonical paths inside the app</strong> — users still navigate to <code className="text-fuchsia-300/90">/w/slug</code> internally so relative links and prefetch stay coherent.
|
||||
</li>
|
||||
<li className="rounded-lg border border-zinc-800 bg-zinc-900/40 p-4">
|
||||
<strong className="text-fuchsia-200">Cross-vertical links</strong> — hub, market, and forum URLs in copy intentionally use absolute paths so they resolve correctly from any onion host after rewrite.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<Link href="/syndicate/topology" className="text-fuchsia-400 hover:underline">
|
||||
← Topology
|
||||
</Link>
|
||||
{" · "}
|
||||
<Link href="/account/hidden-services" className="text-fuchsia-400 hover:underline">
|
||||
Hidden services / mirrors
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
33
app/syndicate/topology/page.tsx
Normal file
33
app/syndicate/topology/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
import { SYNDICATE_NODES } from "@/lib/syndicateNetwork";
|
||||
|
||||
export default function SyndicateTopologyPage() {
|
||||
return (
|
||||
<article className="space-y-8">
|
||||
<header>
|
||||
<h2 className="text-xl font-bold text-white md:text-2xl">Topology</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm text-zinc-400">
|
||||
Three conceptual layers sit above the raw node list. Each <Link href="/syndicate" className="text-fuchsia-400 underline">shell</Link> inherits hub auth and cart paths; only chrome and copy diverge.
|
||||
</p>
|
||||
</header>
|
||||
<ol className="list-decimal space-y-6 pl-5 text-sm text-zinc-300">
|
||||
<li>
|
||||
<strong className="text-fuchsia-200">Ingress</strong> — Tor hostname maps to a dedicated root via proxy config; the browser still loads the same Next build.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fuchsia-200">Presentation</strong> — <code className="text-fuchsia-400/90">/w/[slug]</code> picks a skin;{" "}
|
||||
<span className="font-mono text-fuchsia-300/80">{SYNDICATE_NODES.length} nodes</span> are registered in{" "}
|
||||
<code className="text-zinc-500">syndicateNetwork</code>.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-fuchsia-200">Persistence</strong> — forum posts, exchange rows, and cart JSON remain namespaced by origin in the usual browser model; operators document mirror bundles so users know which hostname they attached to.
|
||||
</li>
|
||||
</ol>
|
||||
<p className="text-xs text-zinc-500">
|
||||
<Link href="/syndicate/routing" className="text-fuchsia-400 hover:underline">
|
||||
Routing lore →
|
||||
</Link>
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
283
app/testimonials/page.tsx
Normal file
283
app/testimonials/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAccount } from "@/contexts/AccountContext";
|
||||
|
||||
type Testimonial = {
|
||||
id: string;
|
||||
user: string;
|
||||
role: string;
|
||||
quote: string;
|
||||
rating: number;
|
||||
ts?: string;
|
||||
submitted?: boolean;
|
||||
};
|
||||
|
||||
const SEEDS: Testimonial[] = [
|
||||
{ id: "t1", user: "NeonSpectre", role: "Security Researcher", quote: "Tested dozens of setups. CyberLux's per-handle vault and local ledger model is the cleanest I've seen for this type of deployment.", rating: 9 },
|
||||
{ id: "t2", user: "CipherQueen", role: "Privacy Advocate", quote: "The attention to OPSEC detail shows. Account bundles, per-onion identity portability, and BTC-verify flow all work as described.", rating: 9 },
|
||||
{ id: "t3", user: "VoidWalker", role: "Long-Time Buyer", quote: "Been using CyberLux for eight months. Every drop landed on schedule, quality matched the listing, vendor communication was clean.", rating: 10 },
|
||||
{ id: "t4", user: "QuantumGhost", role: "Crypto Trader", quote: "The Bitcoin deposit flow is smoother than most CEX onboarding I've done. One confirmation, txid paste, credit applied. Done.", rating: 9 },
|
||||
{ id: "t5", user: "DataPhantom", role: "Journalist", quote: "The local-first architecture is what kept me here. Nothing leaves the browser unless I put it in an order. That's a real commitment.", rating: 10 },
|
||||
{ id: "t6", user: "ShadowBroker", role: "Vendor", quote: "Setting up a stall via the exchange and barter boards is straightforward. Dispute handling in the forum works as expected.", rating: 9 },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "cyberlux-testimonials-v1";
|
||||
|
||||
function loadStored(): Testimonial[] {
|
||||
if (typeof window === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as Testimonial[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveStored(t: Testimonial[]) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
|
||||
}
|
||||
|
||||
function starBar(n: number) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 flex-1 rounded-full ${i < n ? "bg-cyan-500" : "bg-gray-800"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TestimonialsPage() {
|
||||
const { user } = useAccount();
|
||||
const [submitted, setSubmitted] = useState<Testimonial[]>([]);
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
|
||||
const [formQuote, setFormQuote] = useState("");
|
||||
const [formRole, setFormRole] = useState("");
|
||||
const [formRating, setFormRating] = useState(0);
|
||||
const [formHover, setFormHover] = useState(0);
|
||||
const [formStatus, setFormStatus] = useState<"idle" | "success" | "error">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
setSubmitted(loadStored());
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formQuote.trim() || formRating === 0) {
|
||||
setFormStatus("error");
|
||||
return;
|
||||
}
|
||||
const t: Testimonial = {
|
||||
id: `u_${Date.now()}`,
|
||||
user: user?.username ?? "anon",
|
||||
role: formRole.trim() || "CyberLux user",
|
||||
quote: formQuote.trim(),
|
||||
rating: formRating,
|
||||
ts: new Date().toLocaleDateString(),
|
||||
submitted: true,
|
||||
};
|
||||
const updated = [...submitted, t];
|
||||
setSubmitted(updated);
|
||||
saveStored(updated);
|
||||
setFormQuote("");
|
||||
setFormRole("");
|
||||
setFormRating(0);
|
||||
setFormStatus("success");
|
||||
setTimeout(() => setFormStatus("idle"), 3000);
|
||||
};
|
||||
|
||||
const all = [...SEEDS, ...submitted];
|
||||
const avgRating = all.reduce((s, t) => s + t.rating, 0) / all.length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-gray-950 to-black text-gray-100">
|
||||
<header className="border-b border-gray-800">
|
||||
<div className="container mx-auto max-w-6xl px-4 py-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-9 w-9 rounded-full bg-gradient-to-r from-cyan-500 to-blue-600" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">User Testimonials</h1>
|
||||
<p className="text-xs text-gray-400">Community feedback — submit yours below</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<Link href="#submit" className="text-gray-300 hover:text-cyan-400">Submit Yours</Link>
|
||||
<Link href="/" className="rounded-full bg-cyan-600 px-5 py-2 font-bold hover:bg-cyan-700">
|
||||
Hub
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 py-14">
|
||||
<div className="rounded-2xl bg-gradient-to-r from-gray-900 to-gray-800 p-10">
|
||||
<span className="rounded-full bg-cyan-900/40 px-4 py-2 text-xs font-bold text-cyan-300">
|
||||
COMMUNITY VOICES
|
||||
</span>
|
||||
<h1 className="mt-6 text-4xl font-bold md:text-5xl">
|
||||
What Our <span className="text-cyan-400">Users Say</span>
|
||||
</h1>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-8">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Avg Rating</div>
|
||||
<div className="text-3xl font-bold text-cyan-400">{avgRating.toFixed(1)} / 10</div>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-gray-700" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Total Reviews</div>
|
||||
<div className="text-3xl font-bold">{all.length}</div>
|
||||
</div>
|
||||
{submitted.length > 0 && (
|
||||
<>
|
||||
<div className="h-10 w-px bg-gray-700" />
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">From this device</div>
|
||||
<div className="text-3xl font-bold text-cyan-300">{submitted.length}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto max-w-6xl px-4 pb-16">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{all.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`glass rounded-2xl border p-7 ${
|
||||
t.submitted ? "border-cyan-700/40 bg-cyan-900/5" : "border-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-cyan-700 to-blue-800 text-base font-bold">
|
||||
{t.user.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">{t.user}</div>
|
||||
<div className="text-xs text-gray-400">{t.role}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xl font-bold text-cyan-400">{t.rating}/10</div>
|
||||
{t.submitted && (
|
||||
<span className="text-[10px] text-cyan-500/70">your submission</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="italic text-gray-300 leading-relaxed">"{t.quote}"</p>
|
||||
{t.ts && <p className="mt-3 text-[10px] text-gray-600">Submitted {t.ts}</p>}
|
||||
<div className="mt-4">{starBar(t.rating)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="submit" className="container mx-auto max-w-6xl px-4 pb-20">
|
||||
<div className="rounded-2xl border border-cyan-900/30 bg-gradient-to-br from-cyan-900/10 to-black p-10">
|
||||
<h2 className="text-3xl font-bold">Share Your Experience</h2>
|
||||
<p className="mt-4 text-gray-400">
|
||||
Stored locally in your browser.{" "}
|
||||
{user ? (
|
||||
<span>
|
||||
Posting as <span className="text-cyan-400">@{user.username}</span>.
|
||||
</span>
|
||||
) : (
|
||||
<Link href="/sign-in?next=/testimonials#submit" className="text-cyan-400 underline">
|
||||
Sign in
|
||||
</Link>
|
||||
)}{" "}
|
||||
No server submission, no account required.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-8 max-w-xl space-y-6">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-bold">Your Role / Context</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formRole}
|
||||
onChange={(e) => setFormRole(e.target.value)}
|
||||
placeholder="e.g. Buyer, Security Researcher, Vendor…"
|
||||
className="w-full rounded-xl border border-gray-800 bg-gray-900 px-4 py-3 text-sm text-gray-200 focus:border-cyan-700 focus:outline-none"
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-bold">Your Testimonial</label>
|
||||
<textarea
|
||||
value={formQuote}
|
||||
onChange={(e) => setFormQuote(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Tell us about your experience…"
|
||||
className="w-full rounded-xl border border-gray-800 bg-gray-900 px-4 py-3 text-sm text-gray-200 focus:border-cyan-700 focus:outline-none resize-none"
|
||||
maxLength={500}
|
||||
/>
|
||||
<div className="mt-1 text-right text-xs text-gray-600">{formQuote.length}/500</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-3 block text-sm font-bold">
|
||||
Rating: <span className="text-cyan-400">{formRating > 0 ? `${formRating}/10` : "select"}</span>
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onMouseEnter={() => setFormHover(n)}
|
||||
onMouseLeave={() => setFormHover(0)}
|
||||
onClick={() => setFormRating(n)}
|
||||
className={`h-10 w-10 rounded-lg text-sm font-bold transition-all ${
|
||||
n <= (formHover || formRating)
|
||||
? "bg-cyan-600 text-white"
|
||||
: "bg-gray-800 text-gray-400 hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formStatus === "error" && (
|
||||
<p className="text-sm text-red-400">Please fill in your testimonial and select a rating.</p>
|
||||
)}
|
||||
{formStatus === "success" && (
|
||||
<p className="text-sm text-cyan-400">✓ Testimonial saved locally — visible above.</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-full bg-gradient-to-r from-cyan-600 to-blue-600 py-4 font-bold hover:opacity-90"
|
||||
>
|
||||
SUBMIT TESTIMONIAL
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="border-t border-gray-800 px-4 py-10 text-center text-sm text-gray-600">
|
||||
<p>© 2026 CyberLux · Testimonials stored client-side · No third-party tracking</p>
|
||||
<div className="mt-3 flex justify-center gap-6">
|
||||
<Link href="/" className="hover:text-gray-300">Hub</Link>
|
||||
<Link href="/trust" className="hover:text-gray-300">Trust Score</Link>
|
||||
<Link href="/reviews" className="hover:text-gray-300">Reviews</Link>
|
||||
<Link href="/forum" className="hover:text-gray-300">Forum</Link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user