Squashed 'website/' content from commit d19b985
git-subtree-dir: website git-subtree-split: d19b98589ec5d235560a210b26195b653a65a808
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_INVEST_API_URL=http://localhost:8090
|
||||||
|
VITE_HELIUS_API_KEY=your-helius-api-key
|
||||||
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
dist/
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
16
index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Orama — Decentralized Cloud Infrastructure</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
invest-api/.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
PORT=8090
|
||||||
|
DB_PATH=invest.db
|
||||||
|
JWT_SECRET=change-me-to-a-random-32-byte-string
|
||||||
|
HELIUS_API_KEY=your-helius-api-key
|
||||||
|
HELIUS_RPC_URL=https://mainnet.helius-rpc.com/?api-key=your-helius-api-key
|
||||||
5
invest-api/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
invest-api
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
25
invest-api/auth/context.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
walletKey contextKey = "wallet"
|
||||||
|
chainKey contextKey = "chain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WithWallet(ctx context.Context, wallet, chain string) context.Context {
|
||||||
|
ctx = context.WithValue(ctx, walletKey, wallet)
|
||||||
|
ctx = context.WithValue(ctx, chainKey, chain)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func WalletFromContext(ctx context.Context) (wallet, chain string, ok bool) {
|
||||||
|
w, wOk := ctx.Value(walletKey).(string)
|
||||||
|
c, cOk := ctx.Value(chainKey).(string)
|
||||||
|
if !wOk || !cOk || w == "" || c == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return w, c, true
|
||||||
|
}
|
||||||
120
invest-api/auth/handler.go
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
ExpiresAt string `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoginHandler(database *sql.DB, jwtSecret string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Chain = strings.ToLower(req.Chain)
|
||||||
|
if req.Wallet == "" || req.Message == "" || req.Signature == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "wallet, message, and signature are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Chain != "sol" && req.Chain != "evm" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "chain must be 'sol' or 'evm'")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate message timestamp (anti-replay: within 5 minutes)
|
||||||
|
if err := validateMessageTimestamp(req.Message); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, fmt.Sprintf("message validation failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
var verifyErr error
|
||||||
|
if req.Chain == "sol" {
|
||||||
|
verifyErr = VerifySolana(req.Wallet, req.Message, req.Signature)
|
||||||
|
} else {
|
||||||
|
verifyErr = VerifyEVM(req.Wallet, req.Message, req.Signature)
|
||||||
|
}
|
||||||
|
if verifyErr != nil {
|
||||||
|
jsonError(w, http.StatusUnauthorized, fmt.Sprintf("signature verification failed: %v", verifyErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert wallet
|
||||||
|
database.Exec(
|
||||||
|
`INSERT INTO wallets (wallet, chain) VALUES (?, ?)
|
||||||
|
ON CONFLICT(wallet) DO UPDATE SET last_seen = CURRENT_TIMESTAMP, chain = ?`,
|
||||||
|
req.Wallet, req.Chain, req.Chain,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
token, expiresAt, err := GenerateToken(req.Wallet, req.Chain, jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to generate token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(LoginResponse{
|
||||||
|
Token: token,
|
||||||
|
Wallet: req.Wallet,
|
||||||
|
Chain: req.Chain,
|
||||||
|
ExpiresAt: expiresAt.Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMessageTimestamp(message string) error {
|
||||||
|
// Extract timestamp from message format:
|
||||||
|
// "...Timestamp: 2026-03-21T12:00:00.000Z"
|
||||||
|
idx := strings.Index(message, "Timestamp: ")
|
||||||
|
if idx == -1 {
|
||||||
|
return fmt.Errorf("message does not contain a timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
tsStr := strings.TrimSpace(message[idx+len("Timestamp: "):])
|
||||||
|
// Handle potential trailing content
|
||||||
|
if newline := strings.Index(tsStr, "\n"); newline != -1 {
|
||||||
|
tsStr = tsStr[:newline]
|
||||||
|
}
|
||||||
|
|
||||||
|
ts, err := time.Parse(time.RFC3339Nano, tsStr)
|
||||||
|
if err != nil {
|
||||||
|
ts, err = time.Parse("2006-01-02T15:04:05.000Z", tsStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid timestamp format: %s", tsStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(ts) > 5*time.Minute {
|
||||||
|
return fmt.Errorf("message timestamp expired (older than 5 minutes)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||||
|
}
|
||||||
57
invest-api/auth/jwt.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TokenExpiry = 24 * time.Hour
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateToken(wallet, chain, secret string) (string, time.Time, error) {
|
||||||
|
expiresAt := time.Now().Add(TokenExpiry)
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Wallet: wallet,
|
||||||
|
Chain: chain,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
Issuer: "orama-invest",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
signed, err := token.SignedString([]byte(secret))
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("failed to sign JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return signed, expiresAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseToken(tokenStr, secret string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
52
invest-api/auth/verify_evm.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ethereum/go-ethereum/crypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyEVM verifies an Ethereum personal_sign signature.
|
||||||
|
func VerifyEVM(wallet, message, signatureHex string) error {
|
||||||
|
// Remove 0x prefix if present
|
||||||
|
sigHex := strings.TrimPrefix(signatureHex, "0x")
|
||||||
|
sigBytes, err := hex.DecodeString(sigHex)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode signature hex: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sigBytes) != 65 {
|
||||||
|
return fmt.Errorf("invalid signature length: got %d, want 65", len(sigBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ethereum personal_sign prefix
|
||||||
|
prefixed := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)
|
||||||
|
hash := crypto.Keccak256Hash([]byte(prefixed))
|
||||||
|
|
||||||
|
// Fix recovery ID: some wallets return v=27/28, need v=0/1
|
||||||
|
if sigBytes[64] >= 27 {
|
||||||
|
sigBytes[64] -= 27
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKeyBytes, err := crypto.Ecrecover(hash.Bytes(), sigBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ecrecover failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey, err := crypto.UnmarshalPubkey(pubKeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
recoveredAddr := crypto.PubkeyToAddress(*pubKey)
|
||||||
|
expectedAddr := strings.ToLower(strings.TrimPrefix(wallet, "0x"))
|
||||||
|
recoveredHex := strings.ToLower(recoveredAddr.Hex()[2:])
|
||||||
|
|
||||||
|
if recoveredHex != expectedAddr {
|
||||||
|
return fmt.Errorf("signature verification failed: recovered %s, expected %s", recoveredHex, expectedAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
42
invest-api/auth/verify_solana.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mr-tron/base58"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifySolana verifies a Solana wallet signature.
|
||||||
|
// The signature should be base58 or base64 encoded.
|
||||||
|
// The wallet is the base58-encoded public key.
|
||||||
|
func VerifySolana(wallet, message, signature string) error {
|
||||||
|
pubKeyBytes, err := base58.Decode(wallet)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid wallet address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pubKeyBytes) != ed25519.PublicKeySize {
|
||||||
|
return fmt.Errorf("invalid public key length: got %d, want %d", len(pubKeyBytes), ed25519.PublicKeySize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try base58 first (Phantom default), then base64
|
||||||
|
sigBytes, err := base58.Decode(signature)
|
||||||
|
if err != nil || len(sigBytes) != ed25519.SignatureSize {
|
||||||
|
sigBytes, err = base64.StdEncoding.DecodeString(signature)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to decode signature: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sigBytes) != ed25519.SignatureSize {
|
||||||
|
return fmt.Errorf("invalid signature length: got %d, want %d", len(sigBytes), ed25519.SignatureSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ed25519.Verify(pubKeyBytes, []byte(message), sigBytes) {
|
||||||
|
return fmt.Errorf("signature verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
50
invest-api/config/config.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Port string
|
||||||
|
DBPath string
|
||||||
|
JWTSecret string
|
||||||
|
HeliusAPIKey string
|
||||||
|
HeliusRPCURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
// Load .env file if it exists (ignore error if missing)
|
||||||
|
_ = godotenv.Load()
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Port: envOrDefault("PORT", "8090"),
|
||||||
|
DBPath: envOrDefault("DB_PATH", "invest.db"),
|
||||||
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
HeliusAPIKey: os.Getenv("HELIUS_API_KEY"),
|
||||||
|
HeliusRPCURL: os.Getenv("HELIUS_RPC_URL"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
return nil, fmt.Errorf("JWT_SECRET environment variable is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.HeliusAPIKey == "" {
|
||||||
|
return nil, fmt.Errorf("HELIUS_API_KEY environment variable is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.HeliusRPCURL == "" {
|
||||||
|
cfg.HeliusRPCURL = "https://mainnet.helius-rpc.com/?api-key=" + cfg.HeliusAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOrDefault(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
21
invest-api/db/db.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database at %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
164
invest-api/db/migrate.go
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Migrate(db *sql.DB) error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS wallets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL UNIQUE,
|
||||||
|
chain TEXT NOT NULL CHECK(chain IN ('sol', 'evm')),
|
||||||
|
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS token_purchases (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
chain TEXT NOT NULL CHECK(chain IN ('sol', 'evm')),
|
||||||
|
amount_paid REAL NOT NULL,
|
||||||
|
pay_currency TEXT NOT NULL,
|
||||||
|
tokens_allocated REAL NOT NULL,
|
||||||
|
tx_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS license_purchases (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
chain TEXT NOT NULL CHECK(chain IN ('sol', 'evm')),
|
||||||
|
amount_paid REAL NOT NULL,
|
||||||
|
pay_currency TEXT NOT NULL,
|
||||||
|
tx_hash TEXT NOT NULL,
|
||||||
|
license_number INTEGER NOT NULL,
|
||||||
|
claimed_via_nft INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS whitelist (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL UNIQUE,
|
||||||
|
chain TEXT NOT NULL CHECK(chain IN ('sol', 'evm')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
wallet TEXT NOT NULL,
|
||||||
|
detail TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS anchat_claims (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL UNIQUE,
|
||||||
|
anchat_balance_at_claim REAL NOT NULL,
|
||||||
|
orama_amount REAL NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'flagged', 'warned', 'revoked')),
|
||||||
|
flagged_at DATETIME,
|
||||||
|
warned_at DATETIME,
|
||||||
|
revoked_at DATETIME,
|
||||||
|
claimed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS nft_license_verification (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wallet TEXT NOT NULL UNIQUE,
|
||||||
|
license_id INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'flagged', 'warned', 'revoked')),
|
||||||
|
flagged_at DATETIME,
|
||||||
|
warned_at DATETIME,
|
||||||
|
revoked_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wallets_wallet ON wallets(wallet);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_token_purchases_wallet ON token_purchases(wallet);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_license_purchases_wallet ON license_purchases(wallet);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_whitelist_wallet ON whitelist(wallet);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_log_created ON activity_log(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anchat_claims_wallet ON anchat_claims(wallet);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_anchat_claims_status ON anchat_claims(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nft_license_verification_wallet ON nft_license_verification(wallet);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nft_license_verification_status ON nft_license_verification(status);
|
||||||
|
`
|
||||||
|
_, err := db.Exec(schema)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add columns if migrating from old schema (SQLite doesn't support IF NOT EXISTS for ALTER)
|
||||||
|
for _, col := range []struct{ table, column, def string }{
|
||||||
|
{"anchat_claims", "status", "TEXT NOT NULL DEFAULT 'active'"},
|
||||||
|
{"anchat_claims", "flagged_at", "DATETIME"},
|
||||||
|
{"anchat_claims", "warned_at", "DATETIME"},
|
||||||
|
{"anchat_claims", "revoked_at", "DATETIME"},
|
||||||
|
} {
|
||||||
|
db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", col.table, col.column, col.def))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed populates initial data if tables are empty (idempotent).
|
||||||
|
func Seed(database *sql.DB) error {
|
||||||
|
var count int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM token_purchases").Scan(&count)
|
||||||
|
if count > 0 {
|
||||||
|
return nil // Already seeded
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Seeding database with initial investor data...")
|
||||||
|
|
||||||
|
tx, err := database.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to begin seed transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// DeBros — 9JEsuEeRpxrJyFVkx1RRXBTG7V6zTeApr33nGrBpcYt3
|
||||||
|
debrosWallet := "9JEsuEeRpxrJyFVkx1RRXBTG7V6zTeApr33nGrBpcYt3"
|
||||||
|
tx.Exec("INSERT OR IGNORE INTO wallets (wallet, chain) VALUES (?, 'sol')", debrosWallet)
|
||||||
|
tx.Exec(
|
||||||
|
"INSERT INTO token_purchases (wallet, chain, amount_paid, pay_currency, tokens_allocated, tx_hash) VALUES (?, 'sol', 25000, 'SOL', 500000, 'seed-debros-token')",
|
||||||
|
debrosWallet,
|
||||||
|
)
|
||||||
|
tx.Exec(
|
||||||
|
"INSERT INTO activity_log (event_type, wallet, detail) VALUES ('token_purchase', '9JEs...pcYt3', '$25,000 — 500,000 $ORAMA')",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ICXCNIKA — AXXGYMTVS7UGens718mKPvuWpRfX9zf4tGzsAqY5TgwZ
|
||||||
|
icxcWallet := "AXXGYMTVS7UGens718mKPvuWpRfX9zf4tGzsAqY5TgwZ"
|
||||||
|
tx.Exec("INSERT OR IGNORE INTO wallets (wallet, chain) VALUES (?, 'sol')", icxcWallet)
|
||||||
|
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
tx.Exec(
|
||||||
|
"INSERT INTO license_purchases (wallet, chain, amount_paid, pay_currency, tx_hash, license_number, claimed_via_nft) VALUES (?, 'sol', 3000, 'SOL', ?, ?, 0)",
|
||||||
|
icxcWallet, fmt.Sprintf("seed-icxcnika-license-%d", i), i,
|
||||||
|
)
|
||||||
|
tx.Exec(
|
||||||
|
"INSERT INTO activity_log (event_type, wallet, detail) VALUES ('license_purchase', 'AXXG...TgwZ', ?)",
|
||||||
|
fmt.Sprintf("License #%d — $3,000", i),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Exec(
|
||||||
|
"INSERT INTO token_purchases (wallet, chain, amount_paid, pay_currency, tokens_allocated, tx_hash) VALUES (?, 'sol', 1000, 'SOL', 20000, 'seed-icxcnika-token')",
|
||||||
|
icxcWallet,
|
||||||
|
)
|
||||||
|
tx.Exec(
|
||||||
|
"INSERT INTO activity_log (event_type, wallet, detail) VALUES ('token_purchase', 'AXXG...TgwZ', '$1,000 — 20,000 $ORAMA')",
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("failed to commit seed data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Seed data inserted: DeBros ($25K tokens) + ICXCNIKA (3 licenses + $1K tokens)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
75
invest-api/db/models.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
// Constants shared across handlers.
|
||||||
|
const (
|
||||||
|
TotalTokenAllocation = 10_000_000
|
||||||
|
TokenPrice = 0.05
|
||||||
|
MinTokenPurchaseUSD = 50.0
|
||||||
|
TotalLicenses = 500
|
||||||
|
LicensePrice = 3000.0
|
||||||
|
|
||||||
|
TeamNFTCollection = "4vd4ct4ohhSsjKdi2QKdRTcEPsgFHMTNwCUR27xNrA73"
|
||||||
|
CommunityNFTCollection = "DV8pjrqEKx7ET5FSFY2pCugnzyZmcDFrWFxzDLqdSzmp"
|
||||||
|
AnchatMint = "EZGb7aaSbCHbE6DcfynezKRc9Bkzxs8stP97H6WEpump"
|
||||||
|
AnchatClaimRate = 0.0025 // 0.25%
|
||||||
|
AnchatMinBalance = 10_000 // Minimum $ANCHAT to be eligible for claim
|
||||||
|
|
||||||
|
TreasurySOL = "CBRv5D69LKD8xmnjcNNcz1jgehEtKzhUdrxdFaBs14CP"
|
||||||
|
TreasuryETH = "0xA3b5Cba1dBD45951F718d1720bE19718159f5B0D"
|
||||||
|
TreasuryBTC = "bc1qzpkjguxh4pl9pdhj76zeztur42prhfed2hd22z"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatsResponse struct {
|
||||||
|
TokensSold float64 `json:"tokens_sold"`
|
||||||
|
TokensRemaining float64 `json:"tokens_remaining"`
|
||||||
|
TokenRaised float64 `json:"token_raised"`
|
||||||
|
LicensesSold int `json:"licenses_sold"`
|
||||||
|
LicensesLeft int `json:"licenses_left"`
|
||||||
|
LicenseRaised float64 `json:"license_raised"`
|
||||||
|
TotalRaised float64 `json:"total_raised"`
|
||||||
|
WhitelistCount int `json:"whitelist_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityEntry struct {
|
||||||
|
EventType string `json:"event_type"`
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeResponse struct {
|
||||||
|
Wallet string `json:"wallet"`
|
||||||
|
Chain string `json:"chain"`
|
||||||
|
TokensPurchased float64 `json:"tokens_purchased"`
|
||||||
|
TokenSpent float64 `json:"tokens_spent"`
|
||||||
|
Licenses []LicenseInfo `json:"licenses"`
|
||||||
|
OnWhitelist bool `json:"on_whitelist"`
|
||||||
|
PurchaseHistory []PurchaseRecord `json:"purchase_history"`
|
||||||
|
AnchatClaimed bool `json:"anchat_claimed"`
|
||||||
|
AnchatOrama float64 `json:"anchat_orama_amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicenseInfo struct {
|
||||||
|
LicenseNumber int `json:"license_number"`
|
||||||
|
ClaimedViaNFT bool `json:"claimed_via_nft"`
|
||||||
|
PurchasedAt string `json:"purchased_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PurchaseRecord struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
TxHash string `json:"tx_hash"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type NftStatusResponse struct {
|
||||||
|
HasTeamNFT bool `json:"has_team_nft"`
|
||||||
|
HasCommunityNFT bool `json:"has_community_nft"`
|
||||||
|
NFTCount int `json:"nft_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnchatBalanceResponse struct {
|
||||||
|
Balance float64 `json:"balance"`
|
||||||
|
ClaimableOrama float64 `json:"claimable_orama"`
|
||||||
|
}
|
||||||
18
invest-api/go.mod
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
module github.com/debros/orama-website/invest-api
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ethereum/go-ethereum v1.15.11
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37
|
||||||
|
github.com/mr-tron/base58 v1.2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
|
||||||
|
github.com/holiman/uint256 v1.3.2 // indirect
|
||||||
|
golang.org/x/crypto v0.35.0 // indirect
|
||||||
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
|
)
|
||||||
20
invest-api/go.sum
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0=
|
||||||
|
github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
|
||||||
|
github.com/ethereum/go-ethereum v1.15.11 h1:JK73WKeu0WC0O1eyX+mdQAVHUV+UR1a9VB/domDngBU=
|
||||||
|
github.com/ethereum/go-ethereum v1.15.11/go.mod h1:mf8YiHIb0GR4x4TipcvBUPxJLw1mFdmxzoDi11sDRoI=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
|
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||||
|
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
|
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||||
|
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||||
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
30
invest-api/handler/activity.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ActivityHandler(database *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := database.Query(
|
||||||
|
"SELECT event_type, wallet, COALESCE(detail, ''), created_at FROM activity_log ORDER BY created_at DESC LIMIT 50",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to query activity log")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
entries := []db.ActivityEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e db.ActivityEntry
|
||||||
|
rows.Scan(&e.EventType, &e.Wallet, &e.Detail, &e.CreatedAt)
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
129
invest-api/handler/anchat.go
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/auth"
|
||||||
|
dbpkg "github.com/debros/orama-website/invest-api/db"
|
||||||
|
"github.com/debros/orama-website/invest-api/helius"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AnchatBalanceHandler(database *sql.DB, heliusClient *helius.Client) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wallet, chain, ok := auth.WalletFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "wallet required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if chain != "sol" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "$ANCHAT is a Solana token — connect with a Solana wallet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current claim status
|
||||||
|
var status string
|
||||||
|
err := database.QueryRow("SELECT status FROM anchat_claims WHERE wallet = ?", wallet).Scan(&status)
|
||||||
|
hasActiveClaim := err == nil && status == "active"
|
||||||
|
hasRevokedClaim := err == nil && status == "revoked"
|
||||||
|
|
||||||
|
balance, err := heliusClient.GetTokenBalance(wallet, dbpkg.AnchatMint)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("failed to check $ANCHAT balance: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claimable := 0.0
|
||||||
|
canClaim := false
|
||||||
|
if balance >= dbpkg.AnchatMinBalance && !hasActiveClaim {
|
||||||
|
claimable = math.Floor(balance * dbpkg.AnchatClaimRate)
|
||||||
|
canClaim = true
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, map[string]any{
|
||||||
|
"balance": balance,
|
||||||
|
"claimable_orama": claimable,
|
||||||
|
"already_claimed": hasActiveClaim,
|
||||||
|
"was_revoked": hasRevokedClaim,
|
||||||
|
"can_claim": canClaim,
|
||||||
|
"min_balance": dbpkg.AnchatMinBalance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func AnchatClaimHandler(database *sql.DB, heliusClient *helius.Client) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wallet, chain, ok := auth.WalletFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "wallet required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if chain != "sol" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "$ANCHAT is a Solana token — connect with a Solana wallet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's an active claim already
|
||||||
|
var status string
|
||||||
|
err := database.QueryRow("SELECT status FROM anchat_claims WHERE wallet = ?", wallet).Scan(&status)
|
||||||
|
if err == nil && status == "active" {
|
||||||
|
jsonError(w, http.StatusConflict, "you already have an active $ORAMA claim")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh balance (bypass cache)
|
||||||
|
balance, err := heliusClient.GetTokenBalanceFresh(wallet, dbpkg.AnchatMint)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("failed to verify $ANCHAT balance: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if balance < dbpkg.AnchatMinBalance {
|
||||||
|
jsonError(w, http.StatusBadRequest,
|
||||||
|
fmt.Sprintf("minimum %s $ANCHAT required (you have %.0f)",
|
||||||
|
formatInt(dbpkg.AnchatMinBalance), balance))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oramaAmount := math.Floor(balance * dbpkg.AnchatClaimRate)
|
||||||
|
if oramaAmount <= 0 {
|
||||||
|
jsonError(w, http.StatusBadRequest, "$ANCHAT balance too low to claim $ORAMA")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert: if previously revoked, update to active; otherwise insert
|
||||||
|
_, err = database.Exec(`
|
||||||
|
INSERT INTO anchat_claims (wallet, anchat_balance_at_claim, orama_amount, status, flagged_at, warned_at, revoked_at)
|
||||||
|
VALUES (?, ?, ?, 'active', NULL, NULL, NULL)
|
||||||
|
ON CONFLICT(wallet) DO UPDATE SET
|
||||||
|
anchat_balance_at_claim = ?,
|
||||||
|
orama_amount = ?,
|
||||||
|
status = 'active',
|
||||||
|
flagged_at = NULL,
|
||||||
|
warned_at = NULL,
|
||||||
|
revoked_at = NULL,
|
||||||
|
claimed_at = CURRENT_TIMESTAMP
|
||||||
|
`, wallet, balance, oramaAmount, balance, oramaAmount)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to record claim")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(database, "anchat_claim", wallet, fmt.Sprintf("%.0f $ANCHAT → %.0f $ORAMA", balance, oramaAmount))
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||||
|
"orama_amount": oramaAmount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatInt(n float64) string {
|
||||||
|
if n >= 1000 {
|
||||||
|
return fmt.Sprintf("%.0fK", n/1000)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.0f", n)
|
||||||
|
}
|
||||||
28
invest-api/handler/helpers.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func jsonResponse(w http.ResponseWriter, status int, data any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
jsonResponse(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func logActivity(database *sql.DB, eventType, wallet, detail string) {
|
||||||
|
truncated := wallet
|
||||||
|
if len(wallet) > 10 {
|
||||||
|
truncated = wallet[:6] + "..." + wallet[len(wallet)-4:]
|
||||||
|
}
|
||||||
|
database.Exec(
|
||||||
|
"INSERT INTO activity_log (event_type, wallet, detail) VALUES (?, ?, ?)",
|
||||||
|
eventType, truncated, detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
79
invest-api/handler/me.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/auth"
|
||||||
|
"github.com/debros/orama-website/invest-api/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MeHandler(database *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wallet, chain, ok := auth.WalletFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "wallet not found in context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := db.MeResponse{
|
||||||
|
Wallet: wallet,
|
||||||
|
Chain: chain,
|
||||||
|
Licenses: []db.LicenseInfo{},
|
||||||
|
PurchaseHistory: []db.PurchaseRecord{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token totals
|
||||||
|
database.QueryRow(
|
||||||
|
"SELECT COALESCE(SUM(tokens_allocated), 0), COALESCE(SUM(amount_paid), 0) FROM token_purchases WHERE wallet = ?",
|
||||||
|
wallet,
|
||||||
|
).Scan(&resp.TokensPurchased, &resp.TokenSpent)
|
||||||
|
|
||||||
|
// Licenses
|
||||||
|
rows, _ := database.Query(
|
||||||
|
"SELECT license_number, claimed_via_nft, created_at FROM license_purchases WHERE wallet = ? ORDER BY created_at",
|
||||||
|
wallet,
|
||||||
|
)
|
||||||
|
if rows != nil {
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var l db.LicenseInfo
|
||||||
|
var claimed int
|
||||||
|
rows.Scan(&l.LicenseNumber, &claimed, &l.PurchasedAt)
|
||||||
|
l.ClaimedViaNFT = claimed == 1
|
||||||
|
resp.Licenses = append(resp.Licenses, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitelist
|
||||||
|
var count int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM whitelist WHERE wallet = ?", wallet).Scan(&count)
|
||||||
|
resp.OnWhitelist = count > 0
|
||||||
|
|
||||||
|
// ANCHAT claim status
|
||||||
|
var claimCount int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM anchat_claims WHERE wallet = ?", wallet).Scan(&claimCount)
|
||||||
|
resp.AnchatClaimed = claimCount > 0
|
||||||
|
if resp.AnchatClaimed {
|
||||||
|
database.QueryRow("SELECT orama_amount FROM anchat_claims WHERE wallet = ?", wallet).Scan(&resp.AnchatOrama)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purchase history (combined)
|
||||||
|
histRows, _ := database.Query(`
|
||||||
|
SELECT 'token' as type, amount_paid, pay_currency, tx_hash, created_at FROM token_purchases WHERE wallet = ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'license' as type, amount_paid, pay_currency, tx_hash, created_at FROM license_purchases WHERE wallet = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, wallet, wallet)
|
||||||
|
if histRows != nil {
|
||||||
|
defer histRows.Close()
|
||||||
|
for histRows.Next() {
|
||||||
|
var p db.PurchaseRecord
|
||||||
|
histRows.Scan(&p.Type, &p.Amount, &p.Currency, &p.TxHash, &p.CreatedAt)
|
||||||
|
resp.PurchaseHistory = append(resp.PurchaseHistory, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
invest-api/handler/nft.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/auth"
|
||||||
|
"github.com/debros/orama-website/invest-api/db"
|
||||||
|
"github.com/debros/orama-website/invest-api/helius"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NftStatusHandler(heliusClient *helius.Client) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wallet, _, ok := auth.WalletFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "wallet required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := heliusClient.CheckNFTs(wallet, db.TeamNFTCollection, db.CommunityNFTCollection)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("failed to check NFTs: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, db.NftStatusResponse{
|
||||||
|
HasTeamNFT: result.HasTeamNFT,
|
||||||
|
HasCommunityNFT: result.HasCommunityNFT,
|
||||||
|
NFTCount: result.Count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
95
invest-api/handler/price.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PriceResponse struct {
|
||||||
|
SolUSD float64 `json:"sol_usd"`
|
||||||
|
EthUSD float64 `json:"eth_usd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
priceCache PriceResponse
|
||||||
|
priceCacheTime time.Time
|
||||||
|
priceMu sync.RWMutex
|
||||||
|
priceTTL = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func PriceHandler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
prices, err := getCachedPrices()
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to fetch prices: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonResponse(w, http.StatusOK, prices)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCachedPrices() (PriceResponse, error) {
|
||||||
|
priceMu.RLock()
|
||||||
|
if time.Since(priceCacheTime) < priceTTL && priceCache.SolUSD > 0 {
|
||||||
|
cached := priceCache
|
||||||
|
priceMu.RUnlock()
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
priceMu.RUnlock()
|
||||||
|
|
||||||
|
prices, err := fetchCoinGeckoPrices()
|
||||||
|
if err != nil {
|
||||||
|
return PriceResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
priceMu.Lock()
|
||||||
|
priceCache = prices
|
||||||
|
priceCacheTime = time.Now()
|
||||||
|
priceMu.Unlock()
|
||||||
|
|
||||||
|
return prices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchCoinGeckoPrices() (PriceResponse, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get("https://api.coingecko.com/api/v3/simple/price?ids=solana,ethereum&vs_currencies=usd")
|
||||||
|
if err != nil {
|
||||||
|
return PriceResponse{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return PriceResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var data struct {
|
||||||
|
Solana struct{ USD float64 `json:"usd"` } `json:"solana"`
|
||||||
|
Ethereum struct{ USD float64 `json:"usd"` } `json:"ethereum"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &data); err != nil {
|
||||||
|
return PriceResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return PriceResponse{
|
||||||
|
SolUSD: data.Solana.USD,
|
||||||
|
EthUSD: data.Ethereum.USD,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSOLPrice returns the cached SOL price (used by purchase verification).
|
||||||
|
func GetSOLPrice() float64 {
|
||||||
|
priceMu.RLock()
|
||||||
|
defer priceMu.RUnlock()
|
||||||
|
return priceCache.SolUSD
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetETHPrice returns the cached ETH price.
|
||||||
|
func GetETHPrice() float64 {
|
||||||
|
priceMu.RLock()
|
||||||
|
defer priceMu.RUnlock()
|
||||||
|
return priceCache.EthUSD
|
||||||
|
}
|
||||||
194
invest-api/handler/purchase.go
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/auth"
|
||||||
|
"github.com/debros/orama-website/invest-api/db"
|
||||||
|
"github.com/debros/orama-website/invest-api/helius"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenPurchaseRequest struct {
|
||||||
|
AmountPaid float64 `json:"amount_paid"`
|
||||||
|
PayCurrency string `json:"pay_currency"`
|
||||||
|
TxHash string `json:"tx_hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LicensePurchaseRequest struct {
|
||||||
|
AmountPaid float64 `json:"amount_paid"`
|
||||||
|
PayCurrency string `json:"pay_currency"`
|
||||||
|
TxHash string `json:"tx_hash"`
|
||||||
|
ClaimedViaNFT bool `json:"claimed_via_nft"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TokenPurchaseHandler(database *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wallet, chain, ok := auth.WalletFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "wallet required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req TokenPurchaseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.TxHash == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "tx_hash is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.AmountPaid < db.MinTokenPurchaseUSD {
|
||||||
|
jsonError(w, http.StatusBadRequest, fmt.Sprintf("minimum purchase is $%.0f", db.MinTokenPurchaseUSD))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokensAllocated := req.AmountPaid / db.TokenPrice
|
||||||
|
|
||||||
|
var sold float64
|
||||||
|
database.QueryRow("SELECT COALESCE(SUM(tokens_allocated), 0) FROM token_purchases").Scan(&sold)
|
||||||
|
if sold+tokensAllocated > db.TotalTokenAllocation {
|
||||||
|
jsonError(w, http.StatusConflict, "not enough tokens remaining in pre-sale allocation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM token_purchases WHERE tx_hash = ?", req.TxHash).Scan(&existing)
|
||||||
|
if existing > 0 {
|
||||||
|
jsonError(w, http.StatusConflict, "transaction already recorded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.Exec(
|
||||||
|
"INSERT INTO token_purchases (wallet, chain, amount_paid, pay_currency, tokens_allocated, tx_hash) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
wallet, chain, req.AmountPaid, req.PayCurrency, tokensAllocated, req.TxHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to record purchase")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(database, "token_purchase", wallet, fmt.Sprintf("%.0f $ORAMA for $%.2f %s", tokensAllocated, req.AmountPaid, req.PayCurrency))
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||||
|
"tokens_allocated": tokensAllocated,
|
||||||
|
"tx_hash": req.TxHash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LicensePurchaseHandler(database *sql.DB, heliusClient *helius.Client) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wallet, chain, ok := auth.WalletFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "wallet required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req LicensePurchaseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// NFT claim: verify on-chain ownership
|
||||||
|
if req.ClaimedViaNFT {
|
||||||
|
if chain != "sol" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "NFT claims require a Solana wallet")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing active NFT claim (allow re-claim if revoked)
|
||||||
|
var nftClaimStatus string
|
||||||
|
claimErr := database.QueryRow(
|
||||||
|
"SELECT status FROM nft_license_verification WHERE wallet = ?", wallet,
|
||||||
|
).Scan(&nftClaimStatus)
|
||||||
|
if claimErr == nil && nftClaimStatus == "active" {
|
||||||
|
jsonError(w, http.StatusConflict, "you already have an active free license claim")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side verification via Helius
|
||||||
|
nftResult, err := heliusClient.CheckNFTs(wallet, db.TeamNFTCollection, db.CommunityNFTCollection)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, fmt.Sprintf("failed to verify NFT ownership: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !nftResult.HasTeamNFT {
|
||||||
|
jsonError(w, http.StatusForbidden, "wallet does not hold a DeBros Team NFT")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if req.TxHash == "" {
|
||||||
|
jsonError(w, http.StatusBadRequest, "tx_hash is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.AmountPaid < db.LicensePrice {
|
||||||
|
jsonError(w, http.StatusBadRequest, fmt.Sprintf("license costs $%.0f", db.LicensePrice))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sold int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM license_purchases").Scan(&sold)
|
||||||
|
if sold >= db.TotalLicenses {
|
||||||
|
jsonError(w, http.StatusConflict, "all licenses have been sold")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
licenseNumber := sold + 1
|
||||||
|
claimedInt := 0
|
||||||
|
if req.ClaimedViaNFT {
|
||||||
|
claimedInt = 1
|
||||||
|
req.AmountPaid = 0
|
||||||
|
req.PayCurrency = "nft_claim"
|
||||||
|
req.TxHash = fmt.Sprintf("nft-claim-%s-%d", wallet[:8], time.Now().Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !req.ClaimedViaNFT {
|
||||||
|
var existing int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM license_purchases WHERE tx_hash = ?", req.TxHash).Scan(&existing)
|
||||||
|
if existing > 0 {
|
||||||
|
jsonError(w, http.StatusConflict, "transaction already recorded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.Exec(
|
||||||
|
"INSERT INTO license_purchases (wallet, chain, amount_paid, pay_currency, tx_hash, license_number, claimed_via_nft) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
wallet, chain, req.AmountPaid, req.PayCurrency, req.TxHash, licenseNumber, claimedInt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to record license purchase")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
detail := fmt.Sprintf("License #%d", licenseNumber)
|
||||||
|
if req.ClaimedViaNFT {
|
||||||
|
detail += " (DeBros NFT claim)"
|
||||||
|
|
||||||
|
// Register in verification table for periodic checks
|
||||||
|
database.Exec(`
|
||||||
|
INSERT INTO nft_license_verification (wallet, license_id, status)
|
||||||
|
VALUES (?, ?, 'active')
|
||||||
|
ON CONFLICT(wallet) DO UPDATE SET
|
||||||
|
license_id = ?,
|
||||||
|
status = 'active',
|
||||||
|
flagged_at = NULL,
|
||||||
|
warned_at = NULL,
|
||||||
|
revoked_at = NULL
|
||||||
|
`, wallet, licenseNumber, licenseNumber)
|
||||||
|
}
|
||||||
|
logActivity(database, "license_purchase", wallet, detail)
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||||
|
"license_number": licenseNumber,
|
||||||
|
"claimed_via_nft": req.ClaimedViaNFT,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
28
invest-api/handler/stats.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func StatsHandler(database *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var stats db.StatsResponse
|
||||||
|
|
||||||
|
database.QueryRow("SELECT COALESCE(SUM(tokens_allocated), 0), COALESCE(SUM(amount_paid), 0) FROM token_purchases").
|
||||||
|
Scan(&stats.TokensSold, &stats.TokenRaised)
|
||||||
|
|
||||||
|
database.QueryRow("SELECT COUNT(*), COALESCE(SUM(amount_paid), 0) FROM license_purchases").
|
||||||
|
Scan(&stats.LicensesSold, &stats.LicenseRaised)
|
||||||
|
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM whitelist").Scan(&stats.WhitelistCount)
|
||||||
|
|
||||||
|
stats.TokensRemaining = db.TotalTokenAllocation - stats.TokensSold
|
||||||
|
stats.LicensesLeft = db.TotalLicenses - stats.LicensesSold
|
||||||
|
stats.TotalRaised = stats.TokenRaised + stats.LicenseRaised
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusOK, stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
invest-api/handler/whitelist.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func WhitelistJoinHandler(database *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wallet, chain, ok := auth.WalletFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, http.StatusUnauthorized, "wallet required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM whitelist WHERE wallet = ?", wallet).Scan(&existing)
|
||||||
|
if existing > 0 {
|
||||||
|
jsonError(w, http.StatusConflict, "wallet already on whitelist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := database.Exec("INSERT INTO whitelist (wallet, chain) VALUES (?, ?)", wallet, chain)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, http.StatusInternalServerError, "failed to join whitelist")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var position int
|
||||||
|
database.QueryRow("SELECT COUNT(*) FROM whitelist").Scan(&position)
|
||||||
|
|
||||||
|
logActivity(database, "whitelist_join", wallet, fmt.Sprintf("Position #%d", position))
|
||||||
|
|
||||||
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
||||||
|
"position": position,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
52
invest-api/helius/cache.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package helius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cacheEntry[T any] struct {
|
||||||
|
value T
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cache[T any] struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
entries map[string]cacheEntry[T]
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCache[T any](ttl time.Duration) *Cache[T] {
|
||||||
|
return &Cache[T]{
|
||||||
|
entries: make(map[string]cacheEntry[T]),
|
||||||
|
ttl: ttl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache[T]) Get(key string) (T, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
entry, ok := c.entries[key]
|
||||||
|
if !ok || time.Now().After(entry.expiresAt) {
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
return entry.value, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache[T]) Set(key string, value T) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.entries[key] = cacheEntry[T]{
|
||||||
|
value: value,
|
||||||
|
expiresAt: time.Now().Add(c.ttl),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache[T]) Delete(key string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
delete(c.entries, key)
|
||||||
|
}
|
||||||
208
invest-api/helius/client.go
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package helius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
rpcURL string
|
||||||
|
apiKey string
|
||||||
|
http *http.Client
|
||||||
|
nftCache *Cache[NFTResult]
|
||||||
|
tokCache *Cache[float64]
|
||||||
|
}
|
||||||
|
|
||||||
|
type NFTResult struct {
|
||||||
|
HasTeamNFT bool
|
||||||
|
HasCommunityNFT bool
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(apiKey, rpcURL string) *Client {
|
||||||
|
return &Client{
|
||||||
|
rpcURL: rpcURL,
|
||||||
|
apiKey: apiKey,
|
||||||
|
http: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
nftCache: NewCache[NFTResult](5 * time.Minute),
|
||||||
|
tokCache: NewCache[float64](5 * time.Minute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckNFTs checks if a wallet holds DeBros Team (100) or Community (700) NFTs.
|
||||||
|
func (c *Client) CheckNFTs(wallet, teamCollection, communityCollection string) (NFTResult, error) {
|
||||||
|
cacheKey := "nft:" + wallet
|
||||||
|
if cached, ok := c.nftCache.Get(cacheKey); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := NFTResult{}
|
||||||
|
|
||||||
|
// Check team NFTs
|
||||||
|
teamCount, err := c.searchAssetsByCollection(wallet, teamCollection)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to check team NFTs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check community NFTs
|
||||||
|
commCount, err := c.searchAssetsByCollection(wallet, communityCollection)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("failed to check community NFTs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.HasTeamNFT = teamCount > 0
|
||||||
|
result.HasCommunityNFT = commCount > 0
|
||||||
|
result.Count = teamCount + commCount
|
||||||
|
|
||||||
|
c.nftCache.Set(cacheKey, result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenBalance returns the token balance for a specific mint.
|
||||||
|
func (c *Client) GetTokenBalance(wallet, mint string) (float64, error) {
|
||||||
|
cacheKey := "token:" + wallet + ":" + mint
|
||||||
|
if cached, ok := c.tokCache.Get(cacheKey); ok {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "token-balance",
|
||||||
|
"method": "getTokenAccountsByOwner",
|
||||||
|
"params": []any{
|
||||||
|
wallet,
|
||||||
|
map[string]string{"mint": mint},
|
||||||
|
map[string]string{"encoding": "jsonParsed"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.rpcCall(body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := resp["result"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := result["value"].([]any)
|
||||||
|
if !ok || len(value) == 0 {
|
||||||
|
c.tokCache.Set(cacheKey, 0)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the first token account
|
||||||
|
account, ok := value[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
balance := extractUIAmount(account)
|
||||||
|
c.tokCache.Set(cacheKey, balance)
|
||||||
|
return balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenBalanceFresh bypasses cache (used for claims).
|
||||||
|
func (c *Client) GetTokenBalanceFresh(wallet, mint string) (float64, error) {
|
||||||
|
cacheKey := "token:" + wallet + ":" + mint
|
||||||
|
c.tokCache.Delete(cacheKey)
|
||||||
|
return c.GetTokenBalance(wallet, mint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) searchAssetsByCollection(owner, collectionAddr string) (int, error) {
|
||||||
|
body := map[string]any{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": "search-assets",
|
||||||
|
"method": "searchAssets",
|
||||||
|
"params": map[string]any{
|
||||||
|
"ownerAddress": owner,
|
||||||
|
"grouping": []any{"collection", collectionAddr},
|
||||||
|
"page": 1,
|
||||||
|
"limit": 1000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.rpcCall(body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := resp["result"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
total, ok := result["total"].(float64)
|
||||||
|
if ok {
|
||||||
|
return int(total), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
items, ok := result["items"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return len(items), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) rpcCall(body map[string]any) (map[string]any, error) {
|
||||||
|
jsonBody, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Post(c.rpcURL, "application/json", bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("helius request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("helius returned status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if errObj, ok := result["error"]; ok {
|
||||||
|
return nil, fmt.Errorf("helius RPC error: %v", errObj)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractUIAmount(account map[string]any) float64 {
|
||||||
|
data, _ := account["account"].(map[string]any)
|
||||||
|
if data == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
dataInner, _ := data["data"].(map[string]any)
|
||||||
|
if dataInner == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
parsed, _ := dataInner["parsed"].(map[string]any)
|
||||||
|
if parsed == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
info, _ := parsed["info"].(map[string]any)
|
||||||
|
if info == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
tokenAmount, _ := info["tokenAmount"].(map[string]any)
|
||||||
|
if tokenAmount == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
uiAmount, _ := tokenAmount["uiAmount"].(float64)
|
||||||
|
return uiAmount
|
||||||
|
}
|
||||||
78
invest-api/main.go
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/config"
|
||||||
|
"github.com/debros/orama-website/invest-api/db"
|
||||||
|
"github.com/debros/orama-website/invest-api/helius"
|
||||||
|
"github.com/debros/orama-website/invest-api/router"
|
||||||
|
"github.com/debros/orama-website/invest-api/verifier"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := db.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database error: %v", err)
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
|
||||||
|
if err := db.Migrate(database); err != nil {
|
||||||
|
log.Fatalf("migration error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Seed(database); err != nil {
|
||||||
|
log.Fatalf("seed error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
heliusClient := helius.NewClient(cfg.HeliusAPIKey, cfg.HeliusRPCURL)
|
||||||
|
handler := router.New(database, cfg.JWTSecret, heliusClient)
|
||||||
|
|
||||||
|
// Start background verifier
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
v := verifier.New(database, heliusClient)
|
||||||
|
go v.Run(ctx)
|
||||||
|
|
||||||
|
// HTTP server with graceful shutdown
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + cfg.Port,
|
||||||
|
Handler: handler,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Printf("invest-api listening on :%s", cfg.Port)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("server error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for shutdown signal
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Println("shutting down...")
|
||||||
|
cancel() // Stop verifier
|
||||||
|
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Fatalf("shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("invest-api stopped")
|
||||||
|
}
|
||||||
41
invest-api/middleware/auth.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAuth validates JWT from Authorization header.
|
||||||
|
// Falls back to X-Wallet-Address/X-Wallet-Chain headers for backward compatibility.
|
||||||
|
func RequireAuth(jwtSecret string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Try JWT first
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
claims, err := auth.ParseToken(tokenStr, jwtSecret)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"invalid or expired token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := auth.WithWallet(r.Context(), claims.Wallet, claims.Chain)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: legacy header-based auth (remove after frontend migration)
|
||||||
|
wallet := strings.TrimSpace(r.Header.Get("X-Wallet-Address"))
|
||||||
|
chain := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Wallet-Chain")))
|
||||||
|
if wallet != "" && (chain == "sol" || chain == "evm") {
|
||||||
|
ctx := auth.WithWallet(r.Context(), wallet, chain)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, `{"error":"authentication required"}`, http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
23
invest-api/middleware/cors.go
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func CORS(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
origin = "*"
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Wallet-Address, X-Wallet-Chain")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
44
invest-api/router/router.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/debros/orama-website/invest-api/auth"
|
||||||
|
"github.com/debros/orama-website/invest-api/handler"
|
||||||
|
"github.com/debros/orama-website/invest-api/helius"
|
||||||
|
mw "github.com/debros/orama-website/invest-api/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func New(database *sql.DB, jwtSecret string, heliusClient *helius.Client) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
requireAuth := mw.RequireAuth(jwtSecret)
|
||||||
|
|
||||||
|
// Public endpoints
|
||||||
|
mux.HandleFunc("GET /api/stats", handler.StatsHandler(database))
|
||||||
|
mux.HandleFunc("GET /api/activity", handler.ActivityHandler(database))
|
||||||
|
mux.HandleFunc("GET /api/price", handler.PriceHandler())
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
mux.HandleFunc("POST /api/auth/login", auth.LoginHandler(database, jwtSecret))
|
||||||
|
|
||||||
|
// Authenticated endpoints
|
||||||
|
authed := http.NewServeMux()
|
||||||
|
authed.HandleFunc("GET /api/me", handler.MeHandler(database))
|
||||||
|
authed.HandleFunc("POST /api/purchase/token", handler.TokenPurchaseHandler(database))
|
||||||
|
authed.HandleFunc("POST /api/purchase/license", handler.LicensePurchaseHandler(database, heliusClient))
|
||||||
|
authed.HandleFunc("POST /api/whitelist/join", handler.WhitelistJoinHandler(database))
|
||||||
|
authed.HandleFunc("GET /api/nft/status", handler.NftStatusHandler(heliusClient))
|
||||||
|
authed.HandleFunc("GET /api/anchat/balance", handler.AnchatBalanceHandler(database, heliusClient))
|
||||||
|
authed.HandleFunc("POST /api/anchat/claim", handler.AnchatClaimHandler(database, heliusClient))
|
||||||
|
|
||||||
|
// Wire authenticated routes through auth middleware
|
||||||
|
mux.Handle("GET /api/me", requireAuth(authed))
|
||||||
|
mux.Handle("POST /api/purchase/", requireAuth(authed))
|
||||||
|
mux.Handle("POST /api/whitelist/", requireAuth(authed))
|
||||||
|
mux.Handle("GET /api/nft/", requireAuth(authed))
|
||||||
|
mux.Handle("GET /api/anchat/", requireAuth(authed))
|
||||||
|
mux.Handle("POST /api/anchat/", requireAuth(authed))
|
||||||
|
|
||||||
|
return mw.CORS(mux)
|
||||||
|
}
|
||||||
10
invest-api/start.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "Building invest-api..."
|
||||||
|
go build -o invest-api .
|
||||||
|
|
||||||
|
echo "Starting invest-api on port ${PORT:-8090}..."
|
||||||
|
./invest-api
|
||||||
210
invest-api/verifier/verifier.go
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
package verifier
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
dbpkg "github.com/debros/orama-website/invest-api/db"
|
||||||
|
"github.com/debros/orama-website/invest-api/helius"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SweepInterval = 30 * time.Minute
|
||||||
|
GracePeriod = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verifier periodically checks that claim holders still hold their assets.
|
||||||
|
// State machine: active → flagged → warned → revoked (each transition requires
|
||||||
|
// the asset to still be missing after GracePeriod).
|
||||||
|
type Verifier struct {
|
||||||
|
db *sql.DB
|
||||||
|
helius *helius.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB, heliusClient *helius.Client) *Verifier {
|
||||||
|
return &Verifier{db: db, helius: heliusClient}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the background sweep loop. Blocks until ctx is cancelled.
|
||||||
|
func (v *Verifier) Run(ctx context.Context) {
|
||||||
|
log.Println("[verifier] starting background verification (interval:", SweepInterval, ")")
|
||||||
|
|
||||||
|
// Run once on startup to catch anything missed during downtime
|
||||||
|
v.sweep()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(SweepInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("[verifier] shutting down")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
v.sweep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) sweep() {
|
||||||
|
log.Println("[verifier] starting sweep...")
|
||||||
|
v.verifyAnchatClaims()
|
||||||
|
v.verifyNftLicenses()
|
||||||
|
log.Println("[verifier] sweep complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── $ANCHAT claim verification ──
|
||||||
|
|
||||||
|
func (v *Verifier) verifyAnchatClaims() {
|
||||||
|
rows, err := v.db.Query(
|
||||||
|
"SELECT id, wallet, status, flagged_at, warned_at FROM anchat_claims WHERE status != 'revoked'",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[verifier] failed to query anchat_claims: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var wallet, status string
|
||||||
|
var flaggedAt, warnedAt sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &wallet, &status, &flaggedAt, &warnedAt); err != nil {
|
||||||
|
log.Printf("[verifier] scan error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
balance, err := v.helius.GetTokenBalanceFresh(wallet, dbpkg.AnchatMint)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[verifier] helius error for %s: %v", truncate(wallet), err)
|
||||||
|
continue // Don't change state on RPC errors
|
||||||
|
}
|
||||||
|
|
||||||
|
holdsAsset := balance >= dbpkg.AnchatMinBalance
|
||||||
|
v.updateState(id, "anchat_claims", wallet, status, holdsAsset, flaggedAt, warnedAt, "anchat_claim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── NFT license verification ──
|
||||||
|
|
||||||
|
func (v *Verifier) verifyNftLicenses() {
|
||||||
|
rows, err := v.db.Query(
|
||||||
|
"SELECT id, wallet, status, flagged_at, warned_at FROM nft_license_verification WHERE status != 'revoked'",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[verifier] failed to query nft_license_verification: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id int
|
||||||
|
var wallet, status string
|
||||||
|
var flaggedAt, warnedAt sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&id, &wallet, &status, &flaggedAt, &warnedAt); err != nil {
|
||||||
|
log.Printf("[verifier] scan error: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nftResult, err := v.helius.CheckNFTs(wallet, dbpkg.TeamNFTCollection, dbpkg.CommunityNFTCollection)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[verifier] helius NFT error for %s: %v", truncate(wallet), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
holdsAsset := nftResult.HasTeamNFT
|
||||||
|
v.updateState(id, "nft_license_verification", wallet, status, holdsAsset, flaggedAt, warnedAt, "nft_license")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State machine logic ──
|
||||||
|
|
||||||
|
func (v *Verifier) updateState(
|
||||||
|
id int,
|
||||||
|
table, wallet, currentStatus string,
|
||||||
|
holdsAsset bool,
|
||||||
|
flaggedAt, warnedAt sql.NullString,
|
||||||
|
claimType string,
|
||||||
|
) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if holdsAsset {
|
||||||
|
// Asset is back — reset to active
|
||||||
|
if currentStatus != "active" {
|
||||||
|
v.db.Exec(
|
||||||
|
"UPDATE "+table+" SET status = 'active', flagged_at = NULL, warned_at = NULL, revoked_at = NULL WHERE id = ?",
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
log.Printf("[verifier] %s %s: RESTORED to active (asset found again)", table, truncate(wallet))
|
||||||
|
v.logActivity(claimType+"_restored", wallet, "Asset detected, claim restored")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asset NOT found — advance state machine
|
||||||
|
switch currentStatus {
|
||||||
|
case "active":
|
||||||
|
// First miss → flag
|
||||||
|
v.db.Exec("UPDATE "+table+" SET status = 'flagged', flagged_at = ? WHERE id = ?", now.Format(time.RFC3339), id)
|
||||||
|
log.Printf("[verifier] %s %s: FLAGGED (asset not found, check 1/3)", table, truncate(wallet))
|
||||||
|
|
||||||
|
case "flagged":
|
||||||
|
// Second miss — check grace period
|
||||||
|
if !flaggedAt.Valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flagTime, err := time.Parse(time.RFC3339, flaggedAt.String)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if now.Sub(flagTime) < GracePeriod {
|
||||||
|
return // Too soon, wait
|
||||||
|
}
|
||||||
|
v.db.Exec("UPDATE "+table+" SET status = 'warned', warned_at = ? WHERE id = ?", now.Format(time.RFC3339), id)
|
||||||
|
log.Printf("[verifier] %s %s: WARNED (asset not found, check 2/3)", table, truncate(wallet))
|
||||||
|
|
||||||
|
case "warned":
|
||||||
|
// Third miss — check grace period from warned_at
|
||||||
|
if !warnedAt.Valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
warnTime, err := time.Parse(time.RFC3339, warnedAt.String)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if now.Sub(warnTime) < GracePeriod {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// REVOKE
|
||||||
|
v.db.Exec("UPDATE "+table+" SET status = 'revoked', revoked_at = ? WHERE id = ?", now.Format(time.RFC3339), id)
|
||||||
|
log.Printf("[verifier] %s %s: REVOKED (asset not found, check 3/3)", table, truncate(wallet))
|
||||||
|
v.logActivity(claimType+"_revoked", wallet, "Claim revoked — asset no longer held")
|
||||||
|
|
||||||
|
// For NFT licenses: also mark the license_purchase as revoked
|
||||||
|
if table == "nft_license_verification" {
|
||||||
|
v.db.Exec(
|
||||||
|
"UPDATE license_purchases SET claimed_via_nft = -1 WHERE wallet = ? AND claimed_via_nft = 1",
|
||||||
|
wallet,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Verifier) logActivity(eventType, wallet, detail string) {
|
||||||
|
truncated := truncate(wallet)
|
||||||
|
v.db.Exec(
|
||||||
|
"INSERT INTO activity_log (event_type, wallet, detail) VALUES (?, ?, ?)",
|
||||||
|
eventType, truncated, detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(wallet string) string {
|
||||||
|
if len(wallet) > 10 {
|
||||||
|
return wallet[:6] + "..." + wallet[len(wallet)-4:]
|
||||||
|
}
|
||||||
|
return wallet
|
||||||
|
}
|
||||||
56
package.json
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "orama-website",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@gsap/react": "^2.1.2",
|
||||||
|
"@mdx-js/react": "^3.1.1",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@react-three/drei": "^10.7.7",
|
||||||
|
"@react-three/fiber": "^9.5.0",
|
||||||
|
"@solana/wallet-adapter-base": "^0.9.27",
|
||||||
|
"@solana/wallet-adapter-react": "^0.15.39",
|
||||||
|
"@solana/wallet-adapter-react-ui": "^0.9.39",
|
||||||
|
"@solana/wallet-adapter-wallets": "^0.19.37",
|
||||||
|
"@solana/web3.js": "^1.98.4",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"ethers": "^6.16.0",
|
||||||
|
"gsap": "^3.14.2",
|
||||||
|
"lightweight-charts": "^5.1.0",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"mermaid": "^11.12.3",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router": "^7.6.1",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"shiki": "^4.0.0",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"three": "^0.183.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@mdx-js/rollup": "^3.1.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
|
"@types/mdx": "^2.0.13",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/three": "^0.183.1",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"tailwindcss": "^4.1.8",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^7.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
13940
pnpm-lock.yaml
generated
Normal file
2
pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/fonts/inter-bold.ttf
Normal file
11
public/fonts/inter-bold.woff
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuFuYAZ9hiA.woff</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
BIN
public/icons/aerodrome.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/icons/email.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/icons/gitbros.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icons/github.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/icons/linktree.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icons/orama-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icons/pancakeswap.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
public/icons/telegram.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
public/icons/uniswap.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
public/icons/x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/icons/youtube.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/images/anchat-screens/1.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
public/images/anchat-screens/10.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
public/images/anchat-screens/11.png
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
public/images/anchat-screens/2.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
public/images/anchat-screens/3.png
Normal file
|
After Width: | Height: | Size: 944 KiB |
BIN
public/images/anchat-screens/4.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
public/images/anchat-screens/5.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
public/images/anchat-screens/6.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
public/images/anchat-screens/7.png
Normal file
|
After Width: | Height: | Size: 266 KiB |
BIN
public/images/anchat-screens/8.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
public/images/anchat-screens/9.png
Normal file
|
After Width: | Height: | Size: 240 KiB |
BIN
public/images/anchat.png
Executable file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/images/anyone-protocol.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/images/debros-logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/images/debrosnet.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/icxcnika.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/images/js.jpg
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
public/images/km.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
public/images/nik.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/images/pen.jpg
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
public/orama-whitepaper-v1.pdf
Normal file
BIN
public/orama-whitepaper-v2.pdf
Normal file
BIN
src/assets/667aeae9202ea23ed1108895_anyone-logo-ext-mono-512.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
src/assets/debrosnet.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src/assets/network-logo.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
src/assets/orama-icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
103
src/components/icons/tech-logos.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export function ReactLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Center dot */}
|
||||||
|
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
||||||
|
{/* Orbit 1 — horizontal */}
|
||||||
|
<ellipse cx="12" cy="12" rx="10" ry="4" />
|
||||||
|
{/* Orbit 2 — tilted right */}
|
||||||
|
<ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(60 12 12)" />
|
||||||
|
{/* Orbit 3 — tilted left */}
|
||||||
|
<ellipse cx="12" cy="12" rx="10" ry="4" transform="rotate(120 12 12)" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NextjsLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* "N" lettermark */}
|
||||||
|
<path d="M5 4v16h2.5V8.5L18.2 21.6l1.8-1.2V4h-2.5v11.5L6.8 2.4 5 4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Stylized "Go" text */}
|
||||||
|
<text
|
||||||
|
x="12"
|
||||||
|
y="16"
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize="14"
|
||||||
|
fontWeight="700"
|
||||||
|
fontFamily="sans-serif"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
Go
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodejsLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Hexagon shape */}
|
||||||
|
<path d="M12 2l8.66 5v10L12 22l-8.66-5V7L12 2z" />
|
||||||
|
{/* Inner "N" mark */}
|
||||||
|
<path d="M9 15V9l3 4 3-4v6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WasmLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* WASM diamond/gear shape */}
|
||||||
|
<path d="M12 1L22 7v10l-10 6L2 17V7l10-6z" fill="none" stroke="currentColor" strokeWidth="1.2" />
|
||||||
|
{/* "W" inside */}
|
||||||
|
<path d="M7 9l1.5 6 1.5-4 1.5 4 1.5-6" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
{/* Small dot accent */}
|
||||||
|
<circle cx="17" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
269
src/components/landing/about-hero-scene.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { useRef, useMemo } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { Float } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
const NODE_COUNT = 24;
|
||||||
|
const INNER_RADIUS = 0.6;
|
||||||
|
const OUTER_RADIUS = 1.6;
|
||||||
|
const PARTICLE_COUNT = 80;
|
||||||
|
|
||||||
|
/* ─── Dot ring (reusable) ─── */
|
||||||
|
function DotRing({ radius, count, color, opacity, dotSize }: {
|
||||||
|
radius: number; count: number; color: string; opacity: number; dotSize: number;
|
||||||
|
}) {
|
||||||
|
const dots = useMemo(() =>
|
||||||
|
Array.from({ length: count }, (_, i) => {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
return [Math.cos(angle) * radius, Math.sin(angle) * radius] as [number, number];
|
||||||
|
}), [radius, count]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
{dots.map(([x, y], i) => (
|
||||||
|
<mesh key={i} position={[x, y, 0]}>
|
||||||
|
<circleGeometry args={[dotSize, 8]} />
|
||||||
|
<meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Core nodes — pulsing spheres in a scattered cluster ─── */
|
||||||
|
function CoreNodes() {
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
|
||||||
|
const nodes = useMemo(() =>
|
||||||
|
Array.from({ length: NODE_COUNT }, (_, i) => {
|
||||||
|
const angle = (i / NODE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.3;
|
||||||
|
const r = INNER_RADIUS + Math.random() * (OUTER_RADIUS - INNER_RADIUS);
|
||||||
|
const y = (Math.random() - 0.5) * 0.6;
|
||||||
|
return {
|
||||||
|
x: Math.cos(angle) * r,
|
||||||
|
y,
|
||||||
|
z: Math.sin(angle) * r,
|
||||||
|
phase: i * 0.4 + Math.random() * 2,
|
||||||
|
size: 0.015 + Math.random() * 0.02,
|
||||||
|
};
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
meshRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
const node = nodes[i];
|
||||||
|
const wave = Math.sin(t * 1.2 + node.phase);
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = 0.2 + 0.5 * Math.max(0, wave);
|
||||||
|
const s = 1 + 0.4 * Math.max(0, wave);
|
||||||
|
mesh.scale.setScalar(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{nodes.map((node, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { meshRefs.current[i] = el; }}
|
||||||
|
position={[node.x, node.y, node.z]}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[node.size, 8, 8]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.3} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Connection lines between nearby nodes ─── */
|
||||||
|
function Connections() {
|
||||||
|
const lineRefs = useRef<(THREE.Line | null)[]>([]);
|
||||||
|
|
||||||
|
const { nodes, pairs } = useMemo(() => {
|
||||||
|
const ns = Array.from({ length: NODE_COUNT }, (_, i) => {
|
||||||
|
const angle = (i / NODE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.3;
|
||||||
|
const r = INNER_RADIUS + Math.random() * (OUTER_RADIUS - INNER_RADIUS);
|
||||||
|
const y = (Math.random() - 0.5) * 0.6;
|
||||||
|
return new THREE.Vector3(Math.cos(angle) * r, y, Math.sin(angle) * r);
|
||||||
|
});
|
||||||
|
const ps: [number, number][] = [];
|
||||||
|
for (let i = 0; i < ns.length; i++) {
|
||||||
|
for (let j = i + 1; j < ns.length; j++) {
|
||||||
|
if (ns[i].distanceTo(ns[j]) < 0.9) {
|
||||||
|
ps.push([i, j]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { nodes: ns, pairs: ps };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
lineRefs.current.forEach((line, idx) => {
|
||||||
|
if (!line) return;
|
||||||
|
const mat = line.material as THREE.LineBasicMaterial;
|
||||||
|
const wave = Math.sin(t * 0.8 + idx * 0.3);
|
||||||
|
mat.opacity = 0.03 + 0.06 * Math.max(0, wave);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{pairs.map(([a, b], i) => {
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints([nodes[a], nodes[b]]);
|
||||||
|
return (
|
||||||
|
<primitive
|
||||||
|
key={i}
|
||||||
|
ref={(el: THREE.Line | null) => { lineRefs.current[i] = el; }}
|
||||||
|
object={new THREE.Line(geo, new THREE.LineBasicMaterial({ color: "#a1a1aa", transparent: true, opacity: 0.05 }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Expanding pulse rings from center ─── */
|
||||||
|
function PulseRings() {
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
const RING_COUNT = 3;
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
meshRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
const phase = (t * 0.3 + i * (1 / RING_COUNT)) % 1;
|
||||||
|
const scale = 0.2 + phase * 2;
|
||||||
|
mesh.scale.set(scale, scale, 1);
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = 0.08 * (1 - phase);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
{Array.from({ length: RING_COUNT }, (_, i) => (
|
||||||
|
<mesh key={i} ref={(el) => { meshRefs.current[i] = el; }}>
|
||||||
|
<ringGeometry args={[0.98, 1, 64]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.05} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Floating ambient particles ─── */
|
||||||
|
function AmbientParticles() {
|
||||||
|
const ref = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
const positions = useMemo(() => {
|
||||||
|
const arr = new Float32Array(PARTICLE_COUNT * 3);
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const r = 0.3 + Math.random() * 2.2;
|
||||||
|
arr[i * 3] = Math.cos(angle) * r;
|
||||||
|
arr[i * 3 + 1] = (Math.random() - 0.5) * 1.5;
|
||||||
|
arr[i * 3 + 2] = Math.sin(angle) * r;
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const arr = ref.current.geometry.attributes.position.array as Float32Array;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
arr[i * 3 + 1] += Math.sin(t * 0.5 + i) * 0.0003;
|
||||||
|
}
|
||||||
|
ref.current.geometry.attributes.position.needsUpdate = true;
|
||||||
|
ref.current.rotation.y = t * 0.02;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<points ref={ref}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute
|
||||||
|
attach="attributes-position"
|
||||||
|
args={[positions, 3]}
|
||||||
|
/>
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial color="#a1a1aa" size={0.008} transparent opacity={0.15} sizeAttenuation />
|
||||||
|
</points>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Center shield icon (icosahedron wireframe) ─── */
|
||||||
|
function CenterShield() {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
const glowRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.rotation.y = t * 0.15;
|
||||||
|
meshRef.current.rotation.x = Math.sin(t * 0.1) * 0.1;
|
||||||
|
}
|
||||||
|
if (glowRef.current) {
|
||||||
|
const mat = glowRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = 0.03 + 0.02 * Math.sin(t * 1.5);
|
||||||
|
const s = 1 + 0.05 * Math.sin(t * 1.5);
|
||||||
|
glowRef.current.scale.setScalar(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh ref={meshRef}>
|
||||||
|
<icosahedronGeometry args={[0.18, 1]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" wireframe transparent opacity={0.25} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={glowRef}>
|
||||||
|
<sphereGeometry args={[0.22, 16, 16]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.03} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Full scene ─── */
|
||||||
|
function AboutNetwork() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.rotation.y = clock.getElapsedTime() * 0.03;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Float speed={0.5} rotationIntensity={0.01} floatIntensity={0.04}>
|
||||||
|
<group ref={groupRef}>
|
||||||
|
<DotRing radius={OUTER_RADIUS} count={80} color="#a1a1aa" opacity={0.03} dotSize={0.004} />
|
||||||
|
<DotRing radius={INNER_RADIUS} count={40} color="#d4d4d8" opacity={0.04} dotSize={0.003} />
|
||||||
|
<CoreNodes />
|
||||||
|
<Connections />
|
||||||
|
<PulseRings />
|
||||||
|
<AmbientParticles />
|
||||||
|
<CenterShield />
|
||||||
|
</group>
|
||||||
|
</Float>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AboutHeroScene() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[550px] -mt-[400px]">
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 2, 2.5], fov: 42 }}
|
||||||
|
dpr={[1, 2]}
|
||||||
|
gl={{ antialias: true, alpha: true, toneMapping: THREE.ACESFilmicToneMapping, toneMappingExposure: 1 }}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.1} />
|
||||||
|
<AboutNetwork />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/landing/cli-install.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { Terminal } from "../ui/terminal";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||||
|
|
||||||
|
export function CliInstall() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="Install the CLI"
|
||||||
|
subtitle="Get started in seconds. Available on macOS, Linux, and from source."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs defaultValue="brew">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="brew">macOS (Homebrew)</TabsTrigger>
|
||||||
|
<TabsTrigger value="apt">Linux (Debian/Ubuntu)</TabsTrigger>
|
||||||
|
<TabsTrigger value="source">From Source</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="brew">
|
||||||
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ prefix: "$", text: "brew install DeBrosOfficial/tap/orama" },
|
||||||
|
{ prefix: "\u2192", text: "Downloading orama..." },
|
||||||
|
{ prefix: "\u2713", text: "orama installed successfully" },
|
||||||
|
{ text: "" },
|
||||||
|
{ prefix: "$", text: "orama --version" },
|
||||||
|
{ prefix: "\u2192", text: "orama v2.0.0" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="apt">
|
||||||
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ prefix: "$", text: "curl -sL https://github.com/DeBrosOfficial/network/releases/latest/download/orama_linux_amd64.deb -o orama.deb" },
|
||||||
|
{ prefix: "$", text: "sudo dpkg -i orama.deb" },
|
||||||
|
{ prefix: "\u2192", text: "Selecting previously unselected package orama." },
|
||||||
|
{ prefix: "\u2713", text: "orama installed successfully" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="source">
|
||||||
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ prefix: "$", text: "go install github.com/DeBrosOfficial/network/cmd/cli@latest" },
|
||||||
|
{ prefix: "\u2192", text: "Downloading modules..." },
|
||||||
|
{ prefix: "\u2713", text: "orama installed to $GOPATH/bin" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
src/components/landing/compute-mesh-scene.tsx
Normal file
@ -0,0 +1,361 @@
|
|||||||
|
import { useRef, useMemo } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { Float } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
const NODE_COUNT = 32;
|
||||||
|
const GRID_SPACING = 1.1;
|
||||||
|
const EDGE_MAX_DIST = 1.6;
|
||||||
|
const PACKET_COUNT = 8;
|
||||||
|
|
||||||
|
interface GridNode {
|
||||||
|
position: THREE.Vector3;
|
||||||
|
phase: number;
|
||||||
|
baseScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Edge {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Packet {
|
||||||
|
edgeIndex: number;
|
||||||
|
progress: number;
|
||||||
|
speed: number;
|
||||||
|
direction: 1 | -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generate nodes on a gently curved grid */
|
||||||
|
function generateGridNodes(): GridNode[] {
|
||||||
|
const nodes: GridNode[] = [];
|
||||||
|
const cols = 6;
|
||||||
|
const rows = 5;
|
||||||
|
|
||||||
|
for (let row = 0; row < rows; row++) {
|
||||||
|
// Offset every other row for a hex-like feel
|
||||||
|
const colsInRow = row % 2 === 0 ? cols : cols - 1;
|
||||||
|
const offsetX = row % 2 === 0 ? 0 : GRID_SPACING * 0.5;
|
||||||
|
|
||||||
|
for (let col = 0; col < colsInRow; col++) {
|
||||||
|
if (nodes.length >= NODE_COUNT) break;
|
||||||
|
|
||||||
|
const x =
|
||||||
|
(col - (colsInRow - 1) / 2) * GRID_SPACING + offsetX;
|
||||||
|
const z = (row - (rows - 1) / 2) * (GRID_SPACING * 0.85);
|
||||||
|
|
||||||
|
// Gentle curve — bowl shape
|
||||||
|
const dist = Math.sqrt(x * x + z * z);
|
||||||
|
const y = dist * dist * 0.03;
|
||||||
|
|
||||||
|
// Slight random jitter
|
||||||
|
const jx = (Math.random() - 0.5) * 0.15;
|
||||||
|
const jz = (Math.random() - 0.5) * 0.15;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
position: new THREE.Vector3(x + jx, y, z + jz),
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
baseScale: 0.03 + Math.random() * 0.015,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generate edges between nearby nodes */
|
||||||
|
function generateEdges(nodes: GridNode[]): Edge[] {
|
||||||
|
const edges: Edge[] = [];
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
if (
|
||||||
|
nodes[i].position.distanceTo(nodes[j].position) < EDGE_MAX_DIST
|
||||||
|
) {
|
||||||
|
edges.push({ from: i, to: j });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Grid node (sphere with pulse) ─── */
|
||||||
|
function GridNodes({ nodes }: { nodes: GridNode[] }) {
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
meshRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
const node = nodes[i];
|
||||||
|
const pulse = Math.sin(t * 1.5 + node.phase);
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = 0.25 + 0.35 * Math.max(0, pulse);
|
||||||
|
const s =
|
||||||
|
node.baseScale * (1 + 0.4 * Math.max(0, pulse));
|
||||||
|
mesh.scale.setScalar(s / node.baseScale);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{nodes.map((node, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
meshRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
position={node.position}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[node.baseScale, 10, 10]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#d4d4d8"
|
||||||
|
transparent
|
||||||
|
opacity={0.3}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Connection lines between nodes ─── */
|
||||||
|
function ConnectionLines({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
}: {
|
||||||
|
nodes: GridNode[];
|
||||||
|
edges: Edge[];
|
||||||
|
}) {
|
||||||
|
const lines = useMemo(() => {
|
||||||
|
return edges.map((edge) => {
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
nodes[edge.from].position,
|
||||||
|
nodes[edge.to].position,
|
||||||
|
]);
|
||||||
|
const mat = new THREE.LineBasicMaterial({
|
||||||
|
color: "#a1a1aa",
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.06,
|
||||||
|
});
|
||||||
|
return new THREE.Line(geo, mat);
|
||||||
|
});
|
||||||
|
}, [nodes, edges]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<primitive key={i} object={line} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Light packets traveling along edges ─── */
|
||||||
|
function Packets({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
}: {
|
||||||
|
nodes: GridNode[];
|
||||||
|
edges: Edge[];
|
||||||
|
}) {
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
const glowRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
const packetsRef = useRef<Packet[]>([]);
|
||||||
|
|
||||||
|
// Initialize packets
|
||||||
|
if (packetsRef.current.length === 0 && edges.length > 0) {
|
||||||
|
packetsRef.current = Array.from(
|
||||||
|
{ length: PACKET_COUNT },
|
||||||
|
() => ({
|
||||||
|
edgeIndex: Math.floor(Math.random() * edges.length),
|
||||||
|
progress: Math.random(),
|
||||||
|
speed: 0.003 + Math.random() * 0.004,
|
||||||
|
direction: (Math.random() > 0.5 ? 1 : -1) as 1 | -1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
packetsRef.current.forEach((packet, i) => {
|
||||||
|
const mesh = meshRefs.current[i];
|
||||||
|
const glow = glowRefs.current[i];
|
||||||
|
if (!mesh || !glow) return;
|
||||||
|
|
||||||
|
// Advance
|
||||||
|
packet.progress += packet.speed * packet.direction;
|
||||||
|
|
||||||
|
// When reaching end, jump to a connected edge
|
||||||
|
if (packet.progress > 1 || packet.progress < 0) {
|
||||||
|
const currentEdge = edges[packet.edgeIndex];
|
||||||
|
const endNode =
|
||||||
|
packet.direction === 1
|
||||||
|
? currentEdge.to
|
||||||
|
: currentEdge.from;
|
||||||
|
|
||||||
|
// Find edges connected to end node
|
||||||
|
const connected = edges
|
||||||
|
.map((e, idx) => ({ e, idx }))
|
||||||
|
.filter(
|
||||||
|
({ e, idx }) =>
|
||||||
|
idx !== packet.edgeIndex &&
|
||||||
|
(e.from === endNode || e.to === endNode),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connected.length > 0) {
|
||||||
|
const next =
|
||||||
|
connected[Math.floor(Math.random() * connected.length)];
|
||||||
|
packet.edgeIndex = next.idx;
|
||||||
|
packet.direction =
|
||||||
|
next.e.from === endNode ? 1 : -1;
|
||||||
|
packet.progress = packet.direction === 1 ? 0 : 1;
|
||||||
|
} else {
|
||||||
|
packet.direction *= -1 as 1 | -1;
|
||||||
|
packet.progress = THREE.MathUtils.clamp(
|
||||||
|
packet.progress,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position along edge
|
||||||
|
const edge = edges[packet.edgeIndex];
|
||||||
|
const from = nodes[edge.from].position;
|
||||||
|
const to = nodes[edge.to].position;
|
||||||
|
const pos = new THREE.Vector3().lerpVectors(
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
packet.progress,
|
||||||
|
);
|
||||||
|
|
||||||
|
mesh.position.copy(pos);
|
||||||
|
glow.position.copy(pos);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{Array.from({ length: PACKET_COUNT }, (_, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
{/* Core */}
|
||||||
|
<mesh
|
||||||
|
ref={(el) => {
|
||||||
|
meshRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.02, 8, 8]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#ffffff"
|
||||||
|
transparent
|
||||||
|
opacity={0.7}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
{/* Glow */}
|
||||||
|
<mesh
|
||||||
|
ref={(el) => {
|
||||||
|
glowRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.06, 8, 8]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#d4d4d8"
|
||||||
|
transparent
|
||||||
|
opacity={0.08}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Deploy burst — periodic flare on a random node ─── */
|
||||||
|
function DeployBursts({ nodes }: { nodes: GridNode[] }) {
|
||||||
|
const ringRef = useRef<THREE.Mesh>(null);
|
||||||
|
const burstNodeRef = useRef(0);
|
||||||
|
const lastBurstRef = useRef(0);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
// Trigger a burst every ~5 seconds
|
||||||
|
if (t - lastBurstRef.current > 5) {
|
||||||
|
burstNodeRef.current = Math.floor(
|
||||||
|
Math.random() * nodes.length,
|
||||||
|
);
|
||||||
|
lastBurstRef.current = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ringRef.current) {
|
||||||
|
const elapsed = t - lastBurstRef.current;
|
||||||
|
const node = nodes[burstNodeRef.current];
|
||||||
|
ringRef.current.position.copy(node.position);
|
||||||
|
|
||||||
|
if (elapsed < 1.5) {
|
||||||
|
const scale = 1 + elapsed * 2;
|
||||||
|
ringRef.current.scale.setScalar(scale);
|
||||||
|
const mat = ringRef.current
|
||||||
|
.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = 0.12 * (1 - elapsed / 1.5);
|
||||||
|
} else {
|
||||||
|
const mat = ringRef.current
|
||||||
|
.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={ringRef} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.08, 0.1, 24]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#d4d4d8"
|
||||||
|
transparent
|
||||||
|
opacity={0}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Full scene ─── */
|
||||||
|
function ComputeMesh() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
const nodes = useMemo(() => generateGridNodes(), []);
|
||||||
|
const edges = useMemo(() => generateEdges(nodes), [nodes]);
|
||||||
|
|
||||||
|
// Static orientation — no spinning
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Float speed={0.5} rotationIntensity={0.01} floatIntensity={0.04}>
|
||||||
|
<group ref={groupRef}>
|
||||||
|
<GridNodes nodes={nodes} />
|
||||||
|
<ConnectionLines nodes={nodes} edges={edges} />
|
||||||
|
<Packets nodes={nodes} edges={edges} />
|
||||||
|
<DeployBursts nodes={nodes} />
|
||||||
|
</group>
|
||||||
|
</Float>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComputeMeshScene() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[550px] -mt-[400px]">
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 3, 3.5], fov: 38 }}
|
||||||
|
dpr={[1, 2]}
|
||||||
|
gl={{
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
toneMapping: THREE.ACESFilmicToneMapping,
|
||||||
|
toneMappingExposure: 1,
|
||||||
|
}}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.1} />
|
||||||
|
<ComputeMesh />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
245
src/components/landing/consensus-scene.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import { useRef, useMemo } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { Float } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
const VALIDATOR_COUNT = 12;
|
||||||
|
const ANGEL_COUNT = 8;
|
||||||
|
const RING_RADIUS = 1.4;
|
||||||
|
const ANGEL_RADIUS = 0.9;
|
||||||
|
|
||||||
|
/* ─── Thin ring made of dots ─── */
|
||||||
|
function DotRing({ radius, count, color, opacity, dotSize }: {
|
||||||
|
radius: number; count: number; color: string; opacity: number; dotSize: number;
|
||||||
|
}) {
|
||||||
|
const dots = useMemo(() => {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
return [Math.cos(angle) * radius, Math.sin(angle) * radius] as [number, number];
|
||||||
|
});
|
||||||
|
}, [radius, count]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
{dots.map(([x, y], i) => (
|
||||||
|
<mesh key={i} position={[x, y, 0]}>
|
||||||
|
<circleGeometry args={[dotSize, 8]} />
|
||||||
|
<meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Validator nodes — small glowing dots on the outer ring ─── */
|
||||||
|
function Validators() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
|
||||||
|
const positions = useMemo(() => {
|
||||||
|
return Array.from({ length: VALIDATOR_COUNT }, (_, i) => {
|
||||||
|
const angle = (i / VALIDATOR_COUNT) * Math.PI * 2;
|
||||||
|
return {
|
||||||
|
x: Math.cos(angle) * RING_RADIUS,
|
||||||
|
z: Math.sin(angle) * RING_RADIUS,
|
||||||
|
phase: i * 0.5,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
meshRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
// Staggered pulse wave around the ring
|
||||||
|
const wave = Math.sin(t * 1.5 - positions[i].phase);
|
||||||
|
mat.opacity = 0.3 + 0.4 * Math.max(0, wave);
|
||||||
|
const s = 1 + 0.3 * Math.max(0, wave);
|
||||||
|
mesh.scale.setScalar(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{positions.map((pos, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
ref={(el) => { meshRefs.current[i] = el; }}
|
||||||
|
position={[pos.x, 0, pos.z]}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.03, 12, 12]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.4} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Angel nodes — teal particles orbiting inner ring ─── */
|
||||||
|
function Angels() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
const glowRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
meshRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
const angle = (i / ANGEL_COUNT) * Math.PI * 2 + t * 0.4;
|
||||||
|
const wobbleY = Math.sin(t * 2.5 + i * 1.2) * 0.08;
|
||||||
|
|
||||||
|
mesh.position.x = Math.cos(angle) * ANGEL_RADIUS;
|
||||||
|
mesh.position.z = Math.sin(angle) * ANGEL_RADIUS;
|
||||||
|
mesh.position.y = wobbleY;
|
||||||
|
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
const pulse = Math.sin(t * 2 + i * 0.8);
|
||||||
|
mat.opacity = 0.5 + 0.3 * Math.max(0, pulse);
|
||||||
|
});
|
||||||
|
|
||||||
|
glowRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
const angle = (i / ANGEL_COUNT) * Math.PI * 2 + t * 0.4;
|
||||||
|
const wobbleY = Math.sin(t * 2.5 + i * 1.2) * 0.08;
|
||||||
|
|
||||||
|
mesh.position.x = Math.cos(angle) * ANGEL_RADIUS;
|
||||||
|
mesh.position.z = Math.sin(angle) * ANGEL_RADIUS;
|
||||||
|
mesh.position.y = wobbleY;
|
||||||
|
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
const pulse = Math.sin(t * 2 + i * 0.8);
|
||||||
|
mat.opacity = 0.06 + 0.08 * Math.max(0, pulse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{Array.from({ length: ANGEL_COUNT }, (_, i) => (
|
||||||
|
<group key={i}>
|
||||||
|
<mesh ref={(el) => { meshRefs.current[i] = el; }}>
|
||||||
|
<sphereGeometry args={[0.02, 8, 8]} />
|
||||||
|
<meshBasicMaterial color="#00d4aa" transparent opacity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh ref={(el) => { glowRefs.current[i] = el; }}>
|
||||||
|
<sphereGeometry args={[0.06, 8, 8]} />
|
||||||
|
<meshBasicMaterial color="#00d4aa" transparent opacity={0.06} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Center block — small, elegant, rotating ─── */
|
||||||
|
function CenterBlock() {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
const pulseRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.rotation.y = t * 0.6;
|
||||||
|
meshRef.current.rotation.z = t * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulse ring expands outward periodically
|
||||||
|
if (pulseRef.current) {
|
||||||
|
const cycle = t % 3;
|
||||||
|
const expanding = cycle < 1;
|
||||||
|
const scale = expanding ? 1 + cycle * 2 : 3;
|
||||||
|
pulseRef.current.scale.setScalar(scale);
|
||||||
|
const mat = pulseRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = expanding ? 0.08 * (1 - cycle) : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh ref={meshRef}>
|
||||||
|
<octahedronGeometry args={[0.1, 0]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.6} wireframe />
|
||||||
|
</mesh>
|
||||||
|
{/* Inner solid */}
|
||||||
|
<mesh rotation={[0, 0.5, 0.5]}>
|
||||||
|
<octahedronGeometry args={[0.05, 0]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.3} />
|
||||||
|
</mesh>
|
||||||
|
{/* Expanding pulse ring */}
|
||||||
|
<mesh ref={pulseRef} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.12, 0.13, 32]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0} side={THREE.DoubleSide} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Connection lines from validators to center ─── */
|
||||||
|
function ConnectionLines() {
|
||||||
|
const lines = useMemo(() => {
|
||||||
|
return Array.from({ length: VALIDATOR_COUNT }, (_, i) => {
|
||||||
|
const angle = (i / VALIDATOR_COUNT) * Math.PI * 2;
|
||||||
|
const x = Math.cos(angle) * RING_RADIUS;
|
||||||
|
const z = Math.sin(angle) * RING_RADIUS;
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
new THREE.Vector3(0, 0, 0),
|
||||||
|
new THREE.Vector3(x, 0, z),
|
||||||
|
]);
|
||||||
|
const mat = new THREE.LineBasicMaterial({ color: "#a1a1aa", transparent: true, opacity: 0.04 });
|
||||||
|
return new THREE.Line(geo, mat);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<primitive key={i} object={line} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Full Scene ─── */
|
||||||
|
function ConsensusNetwork() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.rotation.y = clock.getElapsedTime() * 0.05;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Float speed={0.6} rotationIntensity={0.01} floatIntensity={0.05}>
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{/* Outer validator ring (dots) */}
|
||||||
|
<DotRing radius={RING_RADIUS} count={60} color="#a1a1aa" opacity={0.04} dotSize={0.005} />
|
||||||
|
{/* Inner angel ring (dots) */}
|
||||||
|
<DotRing radius={ANGEL_RADIUS} count={40} color="#00d4aa" opacity={0.03} dotSize={0.004} />
|
||||||
|
|
||||||
|
<ConnectionLines />
|
||||||
|
<Validators />
|
||||||
|
<Angels />
|
||||||
|
<CenterBlock />
|
||||||
|
</group>
|
||||||
|
</Float>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsensusScene() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[550px] -mt-[400px]">
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 2, 2], fov: 45 }}
|
||||||
|
dpr={[1, 2]}
|
||||||
|
gl={{ antialias: true, alpha: true, toneMapping: THREE.ACESFilmicToneMapping, toneMappingExposure: 1 }}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.1} />
|
||||||
|
<ConsensusNetwork />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/landing/contrib-open-source.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
const repos = [
|
||||||
|
{
|
||||||
|
name: "Core Network",
|
||||||
|
url: "https://github.com/DeBrosOfficial/network",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TypeScript SDK",
|
||||||
|
url: "https://github.com/DeBrosOfficial/network-ts-sdk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "RootWallet",
|
||||||
|
url: "https://github.com/DeBrosOfficial/rootwallet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Orama Vault",
|
||||||
|
url: "https://github.com/DeBrosOfficial/orama-vault",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ContribOpenSource() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader title="Pick an issue. Ship a fix." />
|
||||||
|
|
||||||
|
<DashedPanel withCorners withBackground>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<p className="text-muted leading-relaxed">
|
||||||
|
We pair-program with new contributors. Every PR gets reviewed.
|
||||||
|
No question is too basic. The codebase has comprehensive
|
||||||
|
documentation, strict code style guides, and a test suite with
|
||||||
|
CI/CD.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<Button
|
||||||
|
key={repo.name}
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
className="justify-start"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={repo.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{repo.name}
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 ml-2" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button asChild variant="ghost">
|
||||||
|
<Link to="/docs/contributor/dev-setup">
|
||||||
|
Development Setup Guide
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="ghost">
|
||||||
|
<Link to="/docs/contributor/code-style">
|
||||||
|
Code Style Guide
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/landing/contrib-services.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import {
|
||||||
|
Terminal,
|
||||||
|
Globe,
|
||||||
|
Database,
|
||||||
|
Zap,
|
||||||
|
Globe2,
|
||||||
|
HardDrive,
|
||||||
|
Lock,
|
||||||
|
KeyRound,
|
||||||
|
Code,
|
||||||
|
Cpu,
|
||||||
|
Shield,
|
||||||
|
Network,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{ icon: Terminal, title: "CLI", description: "Node management, deployment, monitoring", tech: "Go" },
|
||||||
|
{ icon: Globe, title: "Gateway", description: "API gateway, routing, auth, rate limiting", tech: "Go" },
|
||||||
|
{ icon: Database, title: "RQLite", description: "Distributed SQL with Raft consensus", tech: "Go" },
|
||||||
|
{ icon: Zap, title: "Olric", description: "In-memory distributed cache", tech: "Go" },
|
||||||
|
{ icon: Globe2, title: "CoreDNS", description: "Distributed DNS, custom domains", tech: "Go" },
|
||||||
|
{ icon: HardDrive, title: "IPFS", description: "Content-addressed file storage", tech: "Go" },
|
||||||
|
{ icon: Lock, title: "Vault", description: "Secrets management, Shamir's SSS", tech: "Zig" },
|
||||||
|
{ icon: KeyRound, title: "RootWallet", description: "Multi-chain wallet authentication", tech: "TypeScript" },
|
||||||
|
{ icon: Code, title: "TypeScript SDK", description: "Client library for all services", tech: "TypeScript" },
|
||||||
|
{ icon: Cpu, title: "WASM Runtime", description: "Serverless function execution", tech: "Go" },
|
||||||
|
{ icon: Shield, title: "WireGuard", description: "Encrypted overlay network mesh", tech: "Go" },
|
||||||
|
{ icon: Network, title: "Cluster", description: "Node discovery, health, Hetzner API", tech: "Go" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ContribServices() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section id="services">
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="What you'll work on."
|
||||||
|
subtitle="Orama is a distributed system made of 12+ independent services. Pick what excites you."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-0">
|
||||||
|
{services.map((service) => {
|
||||||
|
const Icon = service.icon;
|
||||||
|
return (
|
||||||
|
<DashedPanel key={service.title} className="p-4 sm:p-5">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="w-4 h-4 text-accent" />
|
||||||
|
<span className="font-display font-semibold text-fg text-sm">
|
||||||
|
{service.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted leading-relaxed">
|
||||||
|
{service.description}
|
||||||
|
</p>
|
||||||
|
<Badge variant="outline" className="w-fit text-[10px]">
|
||||||
|
{service.tech}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/landing/contrib-stack.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
const stacks = [
|
||||||
|
{
|
||||||
|
language: "Go",
|
||||||
|
reason: "Performance, concurrency, and a rich systems ecosystem. Powers the gateway, CLI, and all core services.",
|
||||||
|
items: [
|
||||||
|
"API Gateway (net/http)",
|
||||||
|
"RQLite integration (Raft)",
|
||||||
|
"Olric cache (consistent hashing)",
|
||||||
|
"WireGuard mesh controller",
|
||||||
|
"CLI tooling (cobra)",
|
||||||
|
"WASM runtime (wazero)",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
language: "Zig",
|
||||||
|
reason: "Manual memory control and zero-overhead cryptography. Powers the Vault guardian daemon.",
|
||||||
|
items: [
|
||||||
|
"Vault guardian daemon",
|
||||||
|
"Shamir's SSS (GF(2^8))",
|
||||||
|
"AES-256-GCM encryption",
|
||||||
|
"HMAC-SHA256 authentication",
|
||||||
|
"Binary TCP protocol",
|
||||||
|
"File-per-user storage",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
language: "TypeScript",
|
||||||
|
reason: "Isomorphic code for browser and Node.js. Powers the SDK, RootWallet, and all frontend tooling.",
|
||||||
|
items: [
|
||||||
|
"Network SDK (browser + Node)",
|
||||||
|
"RootWallet SDK",
|
||||||
|
"Website (React + Vite)",
|
||||||
|
"React hooks library",
|
||||||
|
"CLI companion tools",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ContribStack() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="Tech Stack"
|
||||||
|
subtitle="Three languages, each chosen for what it does best."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-0">
|
||||||
|
{stacks.map((stack) => (
|
||||||
|
<DashedPanel key={stack.language} className="p-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h3 className="font-display font-bold text-fg text-lg">
|
||||||
|
{stack.language}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted leading-relaxed">
|
||||||
|
{stack.reason}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="flex flex-col gap-2">
|
||||||
|
{stack.items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="text-sm text-muted font-mono flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-accent text-xs">→</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/landing/cta-section.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import type { Persona } from "../../types/persona";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
import { StatusDot } from "../ui/status-dot";
|
||||||
|
import { Redacted } from "../ui/redacted";
|
||||||
|
|
||||||
|
const ctaContent: Record<
|
||||||
|
Persona,
|
||||||
|
{
|
||||||
|
heading: string;
|
||||||
|
description: ReactNode;
|
||||||
|
buttonText: string;
|
||||||
|
to: string;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
developer: {
|
||||||
|
heading: "Start building in 60 seconds.",
|
||||||
|
description:
|
||||||
|
"Free tier. No credit card. No email. Connect your wallet and deploy.",
|
||||||
|
buttonText: "Start Building",
|
||||||
|
to: "/dashboard",
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
heading: "Start your node today.",
|
||||||
|
description:
|
||||||
|
<>Minimal hardware. Maximum rewards. Join <Redacted /> operators powering the decentralized cloud.</>,
|
||||||
|
buttonText: "Read Setup Guide",
|
||||||
|
to: "/docs/operator/getting-started",
|
||||||
|
},
|
||||||
|
contributor: {
|
||||||
|
heading: "Your first PR is waiting.",
|
||||||
|
description:
|
||||||
|
"Open source. Active development. Real impact. Pick an issue and start contributing.",
|
||||||
|
buttonText: "View on GitHub",
|
||||||
|
to: "https://github.com/DeBrosOfficial",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CtaSection({ persona }: { persona: Persona }) {
|
||||||
|
const content = ctaContent[persona];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section padding="wide">
|
||||||
|
<AnimateIn>
|
||||||
|
<DashedPanel withCorners withBackground>
|
||||||
|
<div className="flex flex-col items-center text-center gap-6 py-8">
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-2">
|
||||||
|
<StatusDot status="active" />
|
||||||
|
<span className="text-xs font-mono text-muted tracking-wider uppercase">50+ Nodes Online</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="font-display font-bold text-2xl lg:text-3xl text-fg">
|
||||||
|
{content.heading}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-muted max-w-lg leading-relaxed">
|
||||||
|
{content.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{content.external ? (
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<a
|
||||||
|
href={content.to}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{content.buttonText}
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 ml-2" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link to={content.to}>{content.buttonText}</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/landing/dev-comparison.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
const comparisons = [
|
||||||
|
{
|
||||||
|
aspect: "Data ownership",
|
||||||
|
legacy: "Stored on their servers",
|
||||||
|
orama: "Encrypted on distributed nodes you choose",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aspect: "Vendor lock-in",
|
||||||
|
legacy: "Deep. Migration is painful.",
|
||||||
|
orama: "None. Fully open source. Self-hostable.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aspect: "Billing",
|
||||||
|
legacy: "Complex. Surprise charges.",
|
||||||
|
orama: "Free tier + Pay in $ORAMA. Pay in crypto.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aspect: "Privacy",
|
||||||
|
legacy: "They can read your data.",
|
||||||
|
orama: "E2E encrypted. Orama Proxy routing.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aspect: "Setup time",
|
||||||
|
legacy: "Hours — VPCs, IAM, YAML configs",
|
||||||
|
orama: "Minutes. One SDK. One CLI command.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
aspect: "Authentication",
|
||||||
|
legacy: "Email, password, OAuth, IAM roles",
|
||||||
|
orama: "Wallet-based. No passwords. No PII.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevComparison() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="What changes when you leave the cloud."
|
||||||
|
subtitle="Traditional clouds rent you infrastructure you never own. Orama gives you the same capabilities on infrastructure owned by the community."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Desktop table layout */}
|
||||||
|
<DashedPanel className="hidden sm:block" withCorners>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_1fr] border-b border-dashed border-border">
|
||||||
|
<div className="p-4 sm:p-5" />
|
||||||
|
<div className="p-4 sm:p-5 border-l border-dashed border-border">
|
||||||
|
<span className="font-mono text-xs tracking-wider uppercase text-muted">
|
||||||
|
Traditional Cloud
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 border-l border-dashed border-border">
|
||||||
|
<span className="font-mono text-xs tracking-wider uppercase text-accent">
|
||||||
|
Orama Network
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data rows */}
|
||||||
|
{comparisons.map((row, i) => (
|
||||||
|
<div
|
||||||
|
key={row.aspect}
|
||||||
|
className={cn(
|
||||||
|
"grid grid-cols-[1fr_1fr_1fr]",
|
||||||
|
i < comparisons.length - 1 &&
|
||||||
|
"border-b border-dashed border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="p-4 sm:p-5">
|
||||||
|
<span className="font-display font-semibold text-fg text-sm">
|
||||||
|
{row.aspect}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 border-l border-dashed border-border">
|
||||||
|
<span className="text-sm text-muted line-through decoration-muted/40">
|
||||||
|
{row.legacy}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 sm:p-5 border-l border-dashed border-border">
|
||||||
|
<span className="text-sm text-fg">{row.orama}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</DashedPanel>
|
||||||
|
|
||||||
|
{/* Mobile card layout */}
|
||||||
|
<div className="flex flex-col gap-4 sm:hidden">
|
||||||
|
{comparisons.map((row) => (
|
||||||
|
<DashedPanel key={row.aspect} className="p-4">
|
||||||
|
<span className="font-display font-semibold text-fg text-sm block mb-3">
|
||||||
|
{row.aspect}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider uppercase text-muted/60">
|
||||||
|
Traditional
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted line-through decoration-muted/40">
|
||||||
|
{row.legacy}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-mono text-[10px] tracking-wider uppercase text-accent">
|
||||||
|
Orama
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-fg">{row.orama}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/components/landing/dev-deploy.tsx
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { Terminal } from "../ui/terminal";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
import {
|
||||||
|
ReactLogo,
|
||||||
|
NextjsLogo,
|
||||||
|
GoLogo,
|
||||||
|
NodejsLogo,
|
||||||
|
WasmLogo,
|
||||||
|
} from "../icons/tech-logos";
|
||||||
|
import type { TerminalLine } from "../ui/terminal";
|
||||||
|
|
||||||
|
interface DeployTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
lines: TerminalLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployTabs: DeployTab[] = [
|
||||||
|
{
|
||||||
|
id: "static",
|
||||||
|
label: "React / Static",
|
||||||
|
lines: [
|
||||||
|
{ prefix: "$", text: "orama deploy static ./dist --name my-app" },
|
||||||
|
{ prefix: "\u2192", text: "Uploading to IPFS... done" },
|
||||||
|
{ prefix: "\u2192", text: "Pinned to 3 nodes" },
|
||||||
|
{ prefix: "\u2713", text: "Live at https://my-app.orama.network" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "nextjs",
|
||||||
|
label: "Next.js SSR",
|
||||||
|
lines: [
|
||||||
|
{ prefix: "$", text: "orama deploy nextjs . --name my-next --ssr" },
|
||||||
|
{ prefix: "\u2192", text: "Building standalone output..." },
|
||||||
|
{ prefix: "\u2192", text: "Deploying to 3 nodes" },
|
||||||
|
{ prefix: "\u2713", text: "SSR running at https://my-next.orama.network" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "go",
|
||||||
|
label: "Go API",
|
||||||
|
lines: [
|
||||||
|
{ prefix: "$", text: "orama deploy go ./cmd/api --name my-api" },
|
||||||
|
{ prefix: "\u2192", text: "Cross-compiling linux/amd64..." },
|
||||||
|
{ prefix: "\u2192", text: "Health check /health verified" },
|
||||||
|
{ prefix: "\u2713", text: "API live at https://api.orama.network" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "node",
|
||||||
|
label: "Node.js",
|
||||||
|
lines: [
|
||||||
|
{ prefix: "$", text: "orama deploy nodejs . --name my-server" },
|
||||||
|
{ prefix: "\u2192", text: "Detecting start command..." },
|
||||||
|
{ prefix: "\u2192", text: "Deploying to 3 nodes" },
|
||||||
|
{ prefix: "\u2713", text: "Server running at https://srv.orama.network" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "wasm",
|
||||||
|
label: "WASM Function",
|
||||||
|
lines: [
|
||||||
|
{ prefix: "$", text: "orama function deploy --name resize" },
|
||||||
|
{ prefix: "\u2192", text: "Compiled to WebAssembly" },
|
||||||
|
{ prefix: "\u2192", text: "Deployed network-wide" },
|
||||||
|
{ prefix: "\u2713", text: "Invoke via SDK or HTTP trigger" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevDeploy() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section id="deploy">
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader title="Deploy anything. One command." />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-8">
|
||||||
|
{[
|
||||||
|
{ Logo: ReactLogo, name: "React" },
|
||||||
|
{ Logo: NextjsLogo, name: "Next.js" },
|
||||||
|
{ Logo: GoLogo, name: "Go" },
|
||||||
|
{ Logo: NodejsLogo, name: "Node.js" },
|
||||||
|
{ Logo: WasmLogo, name: "WASM" },
|
||||||
|
].map(({ Logo, name }) => (
|
||||||
|
<div key={name} className="flex flex-col items-center gap-2">
|
||||||
|
<Logo className="w-8 h-8 text-muted" />
|
||||||
|
<span className="text-xs font-mono text-muted">{name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted text-base leading-relaxed max-w-2xl">
|
||||||
|
Static sites, Next.js with SSR, Go APIs, Node.js servers, WASM
|
||||||
|
functions. Deploy from your terminal. No infrastructure to manage. No
|
||||||
|
YAML to write.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Tabs defaultValue="static">
|
||||||
|
<TabsList className="flex-wrap">
|
||||||
|
{deployTabs.map((tab) => (
|
||||||
|
<TabsTrigger key={tab.id} value={tab.id}>
|
||||||
|
{tab.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{deployTabs.map((tab) => (
|
||||||
|
<TabsContent key={tab.id} value={tab.id}>
|
||||||
|
<Terminal lines={tab.lines} />
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted font-mono">
|
||||||
|
Every deploy lands on distributed nodes. Automatic TLS. Health
|
||||||
|
checks. Custom domains. Zero downtime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/landing/dev-dns.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Globe, Lock, Link2 } from "lucide-react";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { Terminal } from "../ui/terminal";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
export function DevDns() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="Your domain. Zero configuration."
|
||||||
|
subtitle="Point your nameservers to Orama and every deployment gets a domain automatically. No Cloudflare. No DNS propagation headaches."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
|
||||||
|
{/* Left — features */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Globe className="w-5 h-5 text-accent shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-display font-semibold text-fg text-sm">Orama Nameservers</p>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Point to ns1.orama.network and ns2.orama.network. Your app gets a subdomain instantly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Lock className="w-5 h-5 text-accent shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-display font-semibold text-fg text-sm">Automatic TLS</p>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Every domain gets a valid certificate. No manual cert management. No renewal headaches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Link2 className="w-5 h-5 text-accent shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-display font-semibold text-fg text-sm">Custom Domains</p>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Add any domain via CLI or SDK. CNAME to your Orama app and it just works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — terminal */}
|
||||||
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ prefix: "$", text: "orama domain add myapp.com" },
|
||||||
|
{ prefix: "\u2192", text: "CNAME myapp.com \u2192 my-app.orama.network" },
|
||||||
|
{ prefix: "\u2192", text: "TLS certificate provisioned" },
|
||||||
|
{ prefix: "\u2713", text: "Domain active \u2014 https://myapp.com" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/landing/dev-features.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Database,
|
||||||
|
HardDrive,
|
||||||
|
Cpu,
|
||||||
|
Archive,
|
||||||
|
Radio,
|
||||||
|
Video,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { FeatureCard } from "../ui/feature-card";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Database className="w-5 h-5" />,
|
||||||
|
title: "Network SQL",
|
||||||
|
subtitle: "replaces RDS / Aurora",
|
||||||
|
description:
|
||||||
|
"Distributed SQL with Raft consensus. ACID transactions. Automatic failover across nodes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <HardDrive className="w-5 h-5" />,
|
||||||
|
title: "Network Cache",
|
||||||
|
subtitle: "replaces ElastiCache",
|
||||||
|
description:
|
||||||
|
"In-memory key-value store with TTL, replication, and namespace isolation via Olric.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Cpu className="w-5 h-5" />,
|
||||||
|
title: "Network Functions",
|
||||||
|
subtitle: "replaces Lambda",
|
||||||
|
description:
|
||||||
|
"Serverless WebAssembly. Write in Go, compile to WASM, deploy network-wide.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Archive className="w-5 h-5" />,
|
||||||
|
title: "Network Storage",
|
||||||
|
subtitle: "replaces S3 / R2",
|
||||||
|
description:
|
||||||
|
"Content-addressed storage on IPFS. Upload, pin, and retrieve from any node.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Radio className="w-5 h-5" />,
|
||||||
|
title: "Network PubSub",
|
||||||
|
subtitle: "replaces SNS / SQS",
|
||||||
|
description:
|
||||||
|
"Real-time topic-based messaging with WebSocket native support.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Video className="w-5 h-5" />,
|
||||||
|
title: "Network WebRTC",
|
||||||
|
subtitle: "replaces Twilio / Daily",
|
||||||
|
description:
|
||||||
|
"SFU + TURN servers for video, audio, and data channels.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Globe className="w-5 h-5" />,
|
||||||
|
title: "Network DNS",
|
||||||
|
subtitle: "replaces Route53",
|
||||||
|
description:
|
||||||
|
"CoreDNS distributed across the mesh. Custom domains built in.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Lock className="w-5 h-5" />,
|
||||||
|
title: "Network Vault",
|
||||||
|
subtitle: "replaces Secrets Manager",
|
||||||
|
description:
|
||||||
|
"Shamir's Secret Sharing. Secrets split across nodes. No single point of compromise.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevFeatures() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="A complete cloud. Zero infrastructure."
|
||||||
|
subtitle="No databases to provision. No cache to configure. Import the SDK and you have everything."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-0">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<FeatureCard
|
||||||
|
key={feature.title}
|
||||||
|
icon={feature.icon}
|
||||||
|
title={feature.title}
|
||||||
|
subtitle={feature.subtitle}
|
||||||
|
description={feature.description}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/components/landing/dev-quickstart.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { SyntaxCodeBlock } from "../ui/syntax-code-block";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
const sdkCode = `import { OramaClient } from '@debros/network-ts-sdk'
|
||||||
|
|
||||||
|
const client = new OramaClient({
|
||||||
|
gateway: 'https://orama-testnet.network',
|
||||||
|
apiKey: process.env.ORAMA_API_KEY
|
||||||
|
})
|
||||||
|
|
||||||
|
// SQL Database (like MySQL, powered by RQLite)
|
||||||
|
const users = await client.db.query(
|
||||||
|
'SELECT * FROM users WHERE active = true'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Key-Value Cache (like Redis, powered by Olric)
|
||||||
|
await client.kv.set('session:abc', { theme: 'dark' }, { ttl: 3600 })
|
||||||
|
|
||||||
|
// Real-Time Messaging
|
||||||
|
client.pubsub.subscribe('chat:lobby', (msg) => {
|
||||||
|
console.log(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// File Storage (IPFS)
|
||||||
|
const cid = await client.storage.upload(myFile)
|
||||||
|
|
||||||
|
// Serverless Functions (WASM)
|
||||||
|
await client.functions.invoke('resize-image', { cid, width: 800 })`;
|
||||||
|
|
||||||
|
const setupSteps = [
|
||||||
|
"Connect with Root Wallet",
|
||||||
|
"Get your API key from the dashboard",
|
||||||
|
"Import the SDK and start building",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevQuickstart() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="One SDK. Every service."
|
||||||
|
subtitle="Login with your wallet. Get your API key. That's your entire setup. No databases to provision, no servers to configure."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Setup steps */}
|
||||||
|
<DashedPanel withBackground className="max-w-2xl mx-auto w-full">
|
||||||
|
<div className="flex flex-col gap-3 p-4 sm:p-6">
|
||||||
|
{setupSteps.map((step, i) => (
|
||||||
|
<div key={step} className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline" className="shrink-0 font-mono">
|
||||||
|
{i + 1}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-sm text-fg">{step}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
|
||||||
|
{/* Code block */}
|
||||||
|
<SyntaxCodeBlock code={sdkCode} label="FIG.02 — ORAMACLIENT USAGE" />
|
||||||
|
|
||||||
|
{/* Explainer */}
|
||||||
|
<p className="text-sm text-muted max-w-2xl mx-auto text-center">
|
||||||
|
RQLite is our distributed SQL database — like MySQL but with Raft consensus and automatic failover.
|
||||||
|
Olric is our distributed cache — like Redis but built into every node. They're already running on the network. You just use them.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Works with React, Next.js, Node.js, and any JavaScript runtime.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button asChild variant="link" size="sm">
|
||||||
|
<Link to="/docs/developer/sdk">SDK Docs</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="link" size="sm">
|
||||||
|
<a
|
||||||
|
href="https://github.com/DeBrosOfficial/network-ts-sdk"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
<ExternalLink className="w-3 h-3 ml-1" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/components/landing/docs-section.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import { BookOpen, ArrowRight, FileText, Terminal, Code } from "lucide-react";
|
||||||
|
import type { Persona } from "../../types/persona";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
interface DocLink {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const docsContent: Record<Persona, { subtitle: string; links: DocLink[] }> = {
|
||||||
|
developer: {
|
||||||
|
subtitle: "Guides and references to help you deploy, manage, and scale on Orama.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: "Getting Started",
|
||||||
|
description: "Deploy your first app in under a minute",
|
||||||
|
href: "/docs",
|
||||||
|
icon: <BookOpen className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "CLI Reference",
|
||||||
|
description: "Full command reference for the Orama CLI",
|
||||||
|
href: "/docs",
|
||||||
|
icon: <Terminal className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "SDK & APIs",
|
||||||
|
description: "Integrate Orama services into your application",
|
||||||
|
href: "/docs",
|
||||||
|
icon: <Code className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
subtitle: "Everything you need to set up, configure, and maintain your Orama node.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: "Operator Setup Guide",
|
||||||
|
description: "Install prerequisites and connect your node",
|
||||||
|
href: "/docs/operator/getting-started",
|
||||||
|
icon: <Terminal className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Node Configuration",
|
||||||
|
description: "Hardware requirements, ports, and environment setup",
|
||||||
|
href: "/docs/operator/getting-started",
|
||||||
|
icon: <FileText className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Monitoring & Rewards",
|
||||||
|
description: "Track uptime, performance, and earnings",
|
||||||
|
href: "/docs/operator/getting-started",
|
||||||
|
icon: <BookOpen className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
contributor: {
|
||||||
|
subtitle: "Architecture docs, contribution guidelines, and the full technical stack.",
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
title: "Architecture Overview",
|
||||||
|
description: "How the gateway, services, and mesh work together",
|
||||||
|
href: "/docs",
|
||||||
|
icon: <BookOpen className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Contributing Guide",
|
||||||
|
description: "Setup your dev environment and submit your first PR",
|
||||||
|
href: "/docs",
|
||||||
|
icon: <FileText className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "API Reference",
|
||||||
|
description: "Internal APIs, endpoints, and data models",
|
||||||
|
href: "/docs",
|
||||||
|
icon: <Code className="w-4 h-4" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocsSection({ persona }: { persona: Persona }) {
|
||||||
|
const content = docsContent[persona];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<SectionHeader title="Documentation" subtitle={content.subtitle} />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-0">
|
||||||
|
{content.links.map((doc) => (
|
||||||
|
<Link
|
||||||
|
key={doc.title}
|
||||||
|
to={doc.href}
|
||||||
|
className="group border border-dashed border-border p-5 flex items-start gap-4 transition-all duration-200 hover:bg-surface-2/50 hover:border-border/80"
|
||||||
|
>
|
||||||
|
<div className="text-muted group-hover:text-accent transition-colors shrink-0 mt-0.5">
|
||||||
|
{doc.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-display font-semibold text-fg text-sm">{doc.title}</h3>
|
||||||
|
<ArrowRight className="w-3 h-3 text-muted opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted mt-1 leading-relaxed">{doc.description}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/landing/email-capture.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
export function EmailCapture({
|
||||||
|
heading = "Get early access & updates.",
|
||||||
|
description = "Be the first to know when public node onboarding, the token launch, and mainnet go live.",
|
||||||
|
}: {
|
||||||
|
heading?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
if (!email || !email.includes("@") || !email.includes(".")) {
|
||||||
|
setError("Please enter a valid email address.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Replace with actual API endpoint
|
||||||
|
console.log("Email captured:", email);
|
||||||
|
setSubmitted(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<DashedPanel withCorners withBackground>
|
||||||
|
<div className="flex flex-col items-center text-center gap-5 py-6">
|
||||||
|
<h2 className="font-display font-bold text-2xl lg:text-3xl text-fg">
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted max-w-lg leading-relaxed">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<div className="flex items-center gap-2 text-accent-2 font-mono text-sm tracking-wider uppercase">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent-2 animate-pulse-dot" />
|
||||||
|
You're on the list
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col sm:flex-row items-center gap-3 w-full max-w-md"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
className="w-full flex-1 px-4 py-3 bg-surface-2 border border-border text-fg text-sm font-mono placeholder:text-muted/50 focus:outline-none focus:border-accent/50 transition-colors rounded-sm"
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="default" className="shrink-0 w-full sm:w-auto">
|
||||||
|
Subscribe
|
||||||
|
<ArrowRight className="w-3.5 h-3.5 ml-2" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-xs font-mono">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-muted/50 text-xs font-mono">
|
||||||
|
No spam. Unsubscribe anytime.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
src/components/landing/growth-vault-scene.tsx
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
import { useRef, useMemo } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { Float } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
const PARTICLE_COUNT = 60;
|
||||||
|
const ORAMA_RING_RADIUS = 1.6;
|
||||||
|
const PROXY_RING_RADIUS = 1.2;
|
||||||
|
|
||||||
|
/* ─── Wireframe vault (dodecahedron) ─── */
|
||||||
|
function Vault() {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
const innerRef = useRef<THREE.Mesh>(null);
|
||||||
|
const pulseRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
if (meshRef.current) {
|
||||||
|
meshRef.current.rotation.y = t * 0.15;
|
||||||
|
meshRef.current.rotation.x = Math.sin(t * 0.1) * 0.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner core pulses
|
||||||
|
if (innerRef.current) {
|
||||||
|
const pulse = 0.4 + 0.15 * Math.sin(t * 1.5);
|
||||||
|
const mat = innerRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = pulse;
|
||||||
|
const s = 1 + 0.08 * Math.sin(t * 1.5);
|
||||||
|
innerRef.current.scale.setScalar(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanding pulse ring
|
||||||
|
if (pulseRef.current) {
|
||||||
|
const cycle = t % 4;
|
||||||
|
const expanding = cycle < 1.5;
|
||||||
|
const scale = expanding ? 1 + cycle * 1.5 : 3.25;
|
||||||
|
pulseRef.current.scale.setScalar(scale);
|
||||||
|
const mat = pulseRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = expanding ? 0.06 * (1 - cycle / 1.5) : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* Outer wireframe vault */}
|
||||||
|
<mesh ref={meshRef}>
|
||||||
|
<dodecahedronGeometry args={[0.5, 0]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#d4d4d8"
|
||||||
|
transparent
|
||||||
|
opacity={0.25}
|
||||||
|
wireframe
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Inner glowing core */}
|
||||||
|
<mesh ref={innerRef}>
|
||||||
|
<icosahedronGeometry args={[0.15, 1]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.4} />
|
||||||
|
</mesh>
|
||||||
|
|
||||||
|
{/* Pulse ring */}
|
||||||
|
<mesh ref={pulseRef} rotation={[-Math.PI / 2, 0, 0]}>
|
||||||
|
<ringGeometry args={[0.5, 0.52, 48]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color="#d4d4d8"
|
||||||
|
transparent
|
||||||
|
opacity={0}
|
||||||
|
side={THREE.DoubleSide}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Orbital ring made of dots ─── */
|
||||||
|
function OrbitalRing({
|
||||||
|
radius,
|
||||||
|
count,
|
||||||
|
color,
|
||||||
|
opacity,
|
||||||
|
dotSize,
|
||||||
|
tilt,
|
||||||
|
}: {
|
||||||
|
radius: number;
|
||||||
|
count: number;
|
||||||
|
color: string;
|
||||||
|
opacity: number;
|
||||||
|
dotSize: number;
|
||||||
|
tilt: [number, number, number];
|
||||||
|
}) {
|
||||||
|
const dots = useMemo(() => {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const angle = (i / count) * Math.PI * 2;
|
||||||
|
return [
|
||||||
|
Math.cos(angle) * radius,
|
||||||
|
Math.sin(angle) * radius,
|
||||||
|
] as [number, number];
|
||||||
|
});
|
||||||
|
}, [radius, count]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group rotation={tilt}>
|
||||||
|
{dots.map(([x, y], i) => (
|
||||||
|
<mesh key={i} position={[x, 0, y]}>
|
||||||
|
<sphereGeometry args={[dotSize, 6, 6]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={color}
|
||||||
|
transparent
|
||||||
|
opacity={opacity}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Orbiting token particles ($ORAMA ring) ─── */
|
||||||
|
function OramaOrbit() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
const count = 10;
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
meshRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
const angle = (i / count) * Math.PI * 2 + t * 0.3;
|
||||||
|
mesh.position.x = Math.cos(angle) * ORAMA_RING_RADIUS;
|
||||||
|
mesh.position.z = Math.sin(angle) * ORAMA_RING_RADIUS;
|
||||||
|
mesh.position.y = Math.sin(t * 2 + i * 0.8) * 0.05;
|
||||||
|
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
const pulse = Math.sin(t * 1.5 - i * 0.6);
|
||||||
|
mat.opacity = 0.3 + 0.4 * Math.max(0, pulse);
|
||||||
|
const s = 1 + 0.3 * Math.max(0, pulse);
|
||||||
|
mesh.scale.setScalar(s);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
meshRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.025, 10, 10]} />
|
||||||
|
<meshBasicMaterial color="#d4d4d8" transparent opacity={0.4} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Orbiting token particles (Proxy ring — teal) ─── */
|
||||||
|
function ProxyOrbit() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const meshRefs = useRef<(THREE.Mesh | null)[]>([]);
|
||||||
|
const count = 7;
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
meshRefs.current.forEach((mesh, i) => {
|
||||||
|
if (!mesh) return;
|
||||||
|
// Counter-rotate for visual contrast
|
||||||
|
const angle = (i / count) * Math.PI * 2 - t * 0.4;
|
||||||
|
mesh.position.x = Math.cos(angle) * PROXY_RING_RADIUS;
|
||||||
|
mesh.position.z = Math.sin(angle) * PROXY_RING_RADIUS;
|
||||||
|
mesh.position.y = Math.sin(t * 2.5 + i * 1.2) * 0.06;
|
||||||
|
|
||||||
|
const mat = mesh.material as THREE.MeshBasicMaterial;
|
||||||
|
const pulse = Math.sin(t * 2 + i * 0.9);
|
||||||
|
mat.opacity = 0.4 + 0.3 * Math.max(0, pulse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<mesh
|
||||||
|
key={i}
|
||||||
|
ref={(el) => {
|
||||||
|
meshRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<sphereGeometry args={[0.018, 8, 8]} />
|
||||||
|
<meshBasicMaterial color="#00d4aa" transparent opacity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Inflowing capital particles ─── */
|
||||||
|
function InflowParticles() {
|
||||||
|
const ref = useRef<THREE.Points>(null);
|
||||||
|
|
||||||
|
const { positions, velocities } = useMemo(() => {
|
||||||
|
const pos = new Float32Array(PARTICLE_COUNT * 3);
|
||||||
|
const vel = new Float32Array(PARTICLE_COUNT * 3);
|
||||||
|
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
// Start at random positions on a large sphere
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos(2 * Math.random() - 1);
|
||||||
|
const r = 3 + Math.random() * 2;
|
||||||
|
|
||||||
|
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||||
|
pos[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||||
|
pos[i * 3 + 2] = r * Math.cos(phi);
|
||||||
|
|
||||||
|
// Velocity toward center
|
||||||
|
vel[i * 3] = -pos[i * 3] * 0.008;
|
||||||
|
vel[i * 3 + 1] = -pos[i * 3 + 1] * 0.008;
|
||||||
|
vel[i * 3 + 2] = -pos[i * 3 + 2] * 0.008;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positions: pos, velocities: vel };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const posAttr = ref.current.geometry.attributes
|
||||||
|
.position as THREE.BufferAttribute;
|
||||||
|
const arr = posAttr.array as Float32Array;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||||
|
const ix = i * 3;
|
||||||
|
arr[ix] += velocities[ix];
|
||||||
|
arr[ix + 1] += velocities[ix + 1];
|
||||||
|
arr[ix + 2] += velocities[ix + 2];
|
||||||
|
|
||||||
|
// Reset when close to center
|
||||||
|
const dist = Math.sqrt(
|
||||||
|
arr[ix] ** 2 + arr[ix + 1] ** 2 + arr[ix + 2] ** 2,
|
||||||
|
);
|
||||||
|
if (dist < 0.3) {
|
||||||
|
const theta = Math.random() * Math.PI * 2;
|
||||||
|
const phi = Math.acos(2 * Math.random() - 1);
|
||||||
|
const r = 3 + Math.random() * 2;
|
||||||
|
arr[ix] = r * Math.sin(phi) * Math.cos(theta);
|
||||||
|
arr[ix + 1] = r * Math.sin(phi) * Math.sin(theta);
|
||||||
|
arr[ix + 2] = r * Math.cos(phi);
|
||||||
|
velocities[ix] = -arr[ix] * (0.006 + Math.random() * 0.004);
|
||||||
|
velocities[ix + 1] = -arr[ix + 1] * (0.006 + Math.random() * 0.004);
|
||||||
|
velocities[ix + 2] = -arr[ix + 2] * (0.006 + Math.random() * 0.004);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add slight spiral motion
|
||||||
|
const spiral = 0.002;
|
||||||
|
const cx = arr[ix];
|
||||||
|
const cz = arr[ix + 2];
|
||||||
|
arr[ix] += -cz * spiral;
|
||||||
|
arr[ix + 2] += cx * spiral;
|
||||||
|
}
|
||||||
|
|
||||||
|
posAttr.needsUpdate = true;
|
||||||
|
|
||||||
|
// Slow overall rotation
|
||||||
|
ref.current.rotation.y = t * 0.02;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<points ref={ref}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute
|
||||||
|
attach="attributes-position"
|
||||||
|
args={[positions, 3]}
|
||||||
|
/>
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial
|
||||||
|
size={0.015}
|
||||||
|
color="#d4d4d8"
|
||||||
|
transparent
|
||||||
|
opacity={0.2}
|
||||||
|
sizeAttenuation
|
||||||
|
/>
|
||||||
|
</points>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Full scene composition ─── */
|
||||||
|
function GrowthVaultNetwork() {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (groupRef.current) {
|
||||||
|
groupRef.current.rotation.y = clock.getElapsedTime() * 0.03;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Float speed={0.5} rotationIntensity={0.01} floatIntensity={0.04}>
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{/* Dot rings for orbit paths */}
|
||||||
|
<OrbitalRing
|
||||||
|
radius={ORAMA_RING_RADIUS}
|
||||||
|
count={80}
|
||||||
|
color="#a1a1aa"
|
||||||
|
opacity={0.04}
|
||||||
|
dotSize={0.004}
|
||||||
|
tilt={[0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<OrbitalRing
|
||||||
|
radius={PROXY_RING_RADIUS}
|
||||||
|
count={50}
|
||||||
|
color="#00d4aa"
|
||||||
|
opacity={0.03}
|
||||||
|
dotSize={0.003}
|
||||||
|
tilt={[0.3, 0, 0.2]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vault core */}
|
||||||
|
<Vault />
|
||||||
|
|
||||||
|
{/* Orbiting tokens */}
|
||||||
|
<OramaOrbit />
|
||||||
|
<group rotation={[0.3, 0, 0.2]}>
|
||||||
|
<ProxyOrbit />
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{/* Capital flowing in */}
|
||||||
|
<InflowParticles />
|
||||||
|
</group>
|
||||||
|
</Float>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GrowthVaultScene() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[550px] -mt-[400px]">
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 2, 3], fov: 40 }}
|
||||||
|
dpr={[1, 2]}
|
||||||
|
gl={{
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
toneMapping: THREE.ACESFilmicToneMapping,
|
||||||
|
toneMappingExposure: 1,
|
||||||
|
}}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.1} />
|
||||||
|
<GrowthVaultNetwork />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/components/landing/hero.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import type { Persona } from "../../types/persona";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
const heroContent: Record<
|
||||||
|
Persona,
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
titleLine1: string;
|
||||||
|
titleLine2: string;
|
||||||
|
description: string;
|
||||||
|
primaryCta: { text: string; to: string; external?: boolean };
|
||||||
|
secondaryCta: { text: string; to: string };
|
||||||
|
comingSoon?: boolean;
|
||||||
|
badges: string[];
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
developer: {
|
||||||
|
label: "",
|
||||||
|
titleLine1: "Ship faster. Pay less.",
|
||||||
|
titleLine2: "Own everything.",
|
||||||
|
description:
|
||||||
|
"Deploy your React app, API, or database in one command. No AWS console. No YAML. No surprise bills. Just push and ship.",
|
||||||
|
primaryCta: { text: "Start Building", to: "/dashboard" },
|
||||||
|
secondaryCta: { text: "See Documentation", to: "/docs" },
|
||||||
|
badges: ["Free Tier (No Credit Card)", "Zero Telemetry", "Decentralized Infrastructure"],
|
||||||
|
},
|
||||||
|
operator: {
|
||||||
|
label: "",
|
||||||
|
titleLine1: "Earn by powering",
|
||||||
|
titleLine2: "the decentralized cloud.",
|
||||||
|
description:
|
||||||
|
"Run an Orama node on any VPS. Earn $ORAMA tokens for every request you serve. Join the infrastructure that replaces AWS.",
|
||||||
|
primaryCta: { text: "Become an Operator", to: "/dashboard" },
|
||||||
|
secondaryCta: { text: "See Documentation", to: "/docs" },
|
||||||
|
comingSoon: true,
|
||||||
|
badges: ["$ORAMA Rewards", "Deploy on Any VPS", "100+ Operators"],
|
||||||
|
},
|
||||||
|
contributor: {
|
||||||
|
label: "",
|
||||||
|
titleLine1: "Become a Contributor",
|
||||||
|
titleLine2: "on Orama Network.",
|
||||||
|
description:
|
||||||
|
"Help build the decentralized cloud. From the gateway to the CLI, every service is open source and needs sharp engineers.",
|
||||||
|
primaryCta: {
|
||||||
|
text: "View on GitHub",
|
||||||
|
to: "https://github.com/DeBrosOfficial",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
secondaryCta: { text: "See Documentation", to: "/docs" },
|
||||||
|
badges: ["Open Source", "Go + TypeScript", "Active Community"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function HeroCrosshairGrid() {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none" aria-hidden="true">
|
||||||
|
<svg
|
||||||
|
className="absolute inset-0 w-full h-full opacity-[0.04]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
{/* Vertical center line */}
|
||||||
|
<line
|
||||||
|
x1="50%" y1="0" x2="50%" y2="100%"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="1000"
|
||||||
|
className="text-fg animate-draw-line"
|
||||||
|
style={{ strokeDashoffset: 1000 }}
|
||||||
|
/>
|
||||||
|
{/* Horizontal center line */}
|
||||||
|
<line
|
||||||
|
x1="0" y1="50%" x2="100%" y2="50%"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="1000"
|
||||||
|
className="text-fg animate-draw-line"
|
||||||
|
style={{ animationDelay: "0.3s", strokeDashoffset: 1000 }}
|
||||||
|
/>
|
||||||
|
{/* Center crosshair circle */}
|
||||||
|
<circle
|
||||||
|
cx="50%" cy="50%" r="40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
className="text-accent animate-crosshair-fade"
|
||||||
|
style={{ animationDelay: "0.8s", opacity: 0, animationFillMode: "forwards" }}
|
||||||
|
/>
|
||||||
|
{/* Outer ring */}
|
||||||
|
<circle
|
||||||
|
cx="50%" cy="50%" r="120"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
strokeDasharray="6 6"
|
||||||
|
className="text-accent/50 animate-crosshair-fade"
|
||||||
|
style={{ animationDelay: "1.2s", opacity: 0, animationFillMode: "forwards" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Corner crosshairs */}
|
||||||
|
<div className="absolute top-[15%] left-[15%] w-8 h-8 animate-crosshair-fade" style={{ animationDelay: "1.5s", opacity: 0, animationFillMode: "forwards" }}>
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-px bg-accent/20" />
|
||||||
|
<div className="absolute left-1/2 top-0 h-full w-px bg-accent/20" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-[15%] right-[15%] w-8 h-8 animate-crosshair-fade" style={{ animationDelay: "1.7s", opacity: 0, animationFillMode: "forwards" }}>
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-px bg-accent/20" />
|
||||||
|
<div className="absolute left-1/2 top-0 h-full w-px bg-accent/20" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-[15%] left-[15%] w-8 h-8 animate-crosshair-fade" style={{ animationDelay: "1.9s", opacity: 0, animationFillMode: "forwards" }}>
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-px bg-accent/20" />
|
||||||
|
<div className="absolute left-1/2 top-0 h-full w-px bg-accent/20" />
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-[15%] right-[15%] w-8 h-8 animate-crosshair-fade" style={{ animationDelay: "2.1s", opacity: 0, animationFillMode: "forwards" }}>
|
||||||
|
<div className="absolute top-1/2 left-0 w-full h-px bg-accent/20" />
|
||||||
|
<div className="absolute left-1/2 top-0 h-full w-px bg-accent/20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandingHero({ persona }: { persona: Persona }) {
|
||||||
|
const content = heroContent[persona];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section padding="wide">
|
||||||
|
<div className="relative flex flex-col items-center text-center min-h-[70vh] pt-[12vh] gap-6 max-w-3xl mx-auto">
|
||||||
|
<HeroCrosshairGrid />
|
||||||
|
<Badge variant="default" className="w-fit">
|
||||||
|
{content.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{content.comingSoon && (
|
||||||
|
<Badge variant="status" className="w-fit">
|
||||||
|
COMING SOON
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="font-display font-bold text-4xl lg:text-5xl leading-tight text-fg">
|
||||||
|
{content.titleLine1}
|
||||||
|
<br />
|
||||||
|
<span className="text-accent">{content.titleLine2}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
{content.badges.map((badge) => (
|
||||||
|
<Badge key={badge} variant="outline">
|
||||||
|
{badge}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted text-lg leading-relaxed max-w-xl">
|
||||||
|
{content.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3 justify-center pt-2">
|
||||||
|
{content.primaryCta.external ? (
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<a
|
||||||
|
href={content.primaryCta.to}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{content.primaryCta.text}
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 ml-2" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild size="lg">
|
||||||
|
<Link to={content.primaryCta.to}>
|
||||||
|
{content.primaryCta.text}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{content.secondaryCta.to.startsWith("#") ? (
|
||||||
|
<Button asChild variant="ghost" size="lg">
|
||||||
|
<a href={content.secondaryCta.to}>
|
||||||
|
{content.secondaryCta.text}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button asChild variant="ghost" size="lg">
|
||||||
|
<Link to={content.secondaryCta.to}>
|
||||||
|
{content.secondaryCta.text}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/landing/investor-form.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
|
const WEB3FORMS_ACCESS_KEY = "YOUR_ACCESS_KEY_HERE";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full px-4 py-3 bg-surface-2 border border-border text-fg text-sm font-mono placeholder:text-muted/50 focus:outline-none focus:border-accent/50 transition-colors rounded-sm";
|
||||||
|
|
||||||
|
export function InvestorForm() {
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
formData.append("access_key", WEB3FORMS_ACCESS_KEY);
|
||||||
|
formData.append("subject", "New Investor Inquiry — Orama Network");
|
||||||
|
formData.append("from_name", "Orama Network Website");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://api.web3forms.com/submit", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setSubmitted(true);
|
||||||
|
} else {
|
||||||
|
setError("Something went wrong. Please try again.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<SectionHeader
|
||||||
|
title="Get in Touch"
|
||||||
|
subtitle="Interested in backing Orama Network? We'd love to hear from you."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-8">
|
||||||
|
<DashedPanel withCorners withBackground>
|
||||||
|
{submitted ? (
|
||||||
|
<div className="flex flex-col items-center text-center gap-4 py-8">
|
||||||
|
<div className="flex items-center gap-2 text-accent-2 font-mono text-sm tracking-wider uppercase">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent-2 animate-pulse-dot" />
|
||||||
|
Message Received
|
||||||
|
</div>
|
||||||
|
<p className="text-muted max-w-md">
|
||||||
|
Thanks for reaching out. We'll get back to you shortly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
|
<input type="hidden" name="to" value="dev@debros.io" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
placeholder="Name *"
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email *"
|
||||||
|
required
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="role"
|
||||||
|
placeholder="Role (e.g. VC Partner, Angel, Builder)"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
placeholder="Tell us about your interest in Orama Network..."
|
||||||
|
rows={4}
|
||||||
|
className={`${inputClass} resize-none`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-red-400 text-xs font-mono">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" className="w-full sm:w-auto self-end" disabled={loading}>
|
||||||
|
{loading ? "Sending..." : "Send Message"}
|
||||||
|
{!loading && <ArrowRight className="w-3.5 h-3.5 ml-2" />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DashedPanel>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
src/components/landing/network-visualization.tsx
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import { useRef, useMemo, useState, useEffect } from "react";
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import { Float } from "@react-three/drei";
|
||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────
|
||||||
|
Step 0/1: Nodes appear and connect
|
||||||
|
Step 1/2: Deploy packets flow through the network
|
||||||
|
Step 2/3: User device connects, app loads
|
||||||
|
───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
interface NodeData {
|
||||||
|
position: THREE.Vector3;
|
||||||
|
baseScale: number;
|
||||||
|
phase: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeData {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generate deterministic node positions on a sphere */
|
||||||
|
function generateNodes(count: number, radius: number): NodeData[] {
|
||||||
|
const nodes: NodeData[] = [];
|
||||||
|
const goldenRatio = (1 + Math.sqrt(5)) / 2;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const theta = (2 * Math.PI * i) / goldenRatio;
|
||||||
|
const phi = Math.acos(1 - (2 * (i + 0.5)) / count);
|
||||||
|
const x = radius * Math.sin(phi) * Math.cos(theta);
|
||||||
|
const y = radius * Math.sin(phi) * Math.sin(theta);
|
||||||
|
const z = radius * Math.cos(phi);
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
position: new THREE.Vector3(x, y, z),
|
||||||
|
baseScale: 0.03 + Math.random() * 0.03,
|
||||||
|
phase: Math.random() * Math.PI * 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Generate edges between nearby nodes */
|
||||||
|
function generateEdges(nodes: NodeData[], maxDist: number): EdgeData[] {
|
||||||
|
const edges: EdgeData[] = [];
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
if (nodes[i].position.distanceTo(nodes[j].position) < maxDist) {
|
||||||
|
edges.push({ from: i, to: j });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_COUNT = 40;
|
||||||
|
const SPHERE_RADIUS = 2.8;
|
||||||
|
const EDGE_MAX_DIST = 2.2;
|
||||||
|
|
||||||
|
/* ─── Glowing Node ─── */
|
||||||
|
function GlowNode({
|
||||||
|
node,
|
||||||
|
active,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
node: NodeData;
|
||||||
|
active: boolean;
|
||||||
|
highlight: boolean;
|
||||||
|
}) {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!meshRef.current) return;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const pulse = 1 + 0.3 * Math.sin(t * 2 + node.phase);
|
||||||
|
const targetScale = active ? node.baseScale * pulse : 0;
|
||||||
|
const current = meshRef.current.scale.x;
|
||||||
|
const next = THREE.MathUtils.lerp(current, targetScale, 0.05);
|
||||||
|
meshRef.current.scale.setScalar(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh ref={meshRef} position={node.position} scale={0}>
|
||||||
|
<sphereGeometry args={[1, 12, 12]} />
|
||||||
|
<meshBasicMaterial
|
||||||
|
color={highlight ? "#00d4aa" : "#4169E1"}
|
||||||
|
transparent
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Network Edges ─── */
|
||||||
|
function NetworkEdges({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
nodes: NodeData[];
|
||||||
|
edges: EdgeData[];
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
const linesRef = useRef<THREE.Group>(null);
|
||||||
|
const opacityRef = useRef(0);
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
const target = active ? 0.15 : 0;
|
||||||
|
opacityRef.current = THREE.MathUtils.lerp(opacityRef.current, target, 0.03);
|
||||||
|
|
||||||
|
if (!linesRef.current) return;
|
||||||
|
linesRef.current.children.forEach((child) => {
|
||||||
|
const line = child as THREE.Line;
|
||||||
|
const mat = line.material as THREE.LineBasicMaterial;
|
||||||
|
mat.opacity = opacityRef.current;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lineObjects = useMemo(() => {
|
||||||
|
return edges.map((edge) => {
|
||||||
|
const geo = new THREE.BufferGeometry().setFromPoints([
|
||||||
|
nodes[edge.from].position,
|
||||||
|
nodes[edge.to].position,
|
||||||
|
]);
|
||||||
|
const mat = new THREE.LineBasicMaterial({
|
||||||
|
color: "#4169E1",
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0,
|
||||||
|
});
|
||||||
|
return new THREE.Line(geo, mat);
|
||||||
|
});
|
||||||
|
}, [nodes, edges]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={linesRef}>
|
||||||
|
{lineObjects.map((lineObj, i) => (
|
||||||
|
<primitive key={i} object={lineObj} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Deploy Packets (Step 2) ─── */
|
||||||
|
function DeployPackets({
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
nodes: NodeData[];
|
||||||
|
edges: EdgeData[];
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const packetCount = 12;
|
||||||
|
|
||||||
|
// Each packet travels along a random edge
|
||||||
|
const packetData = useMemo(() => {
|
||||||
|
return Array.from({ length: packetCount }, (_, i) => {
|
||||||
|
const edge = edges[i % edges.length];
|
||||||
|
return {
|
||||||
|
from: nodes[edge.from].position,
|
||||||
|
to: nodes[edge.to].position,
|
||||||
|
speed: 0.3 + Math.random() * 0.4,
|
||||||
|
offset: Math.random(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [nodes, edges]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
|
||||||
|
groupRef.current.children.forEach((child, i) => {
|
||||||
|
const mesh = child as THREE.Mesh;
|
||||||
|
const data = packetData[i];
|
||||||
|
const progress = ((t * data.speed + data.offset) % 1);
|
||||||
|
|
||||||
|
mesh.position.lerpVectors(data.from, data.to, progress);
|
||||||
|
const targetScale = active ? 0.035 : 0;
|
||||||
|
const current = mesh.scale.x;
|
||||||
|
mesh.scale.setScalar(THREE.MathUtils.lerp(current, targetScale, 0.05));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{packetData.map((_, i) => (
|
||||||
|
<mesh key={i} scale={0}>
|
||||||
|
<sphereGeometry args={[1, 8, 8]} />
|
||||||
|
<meshBasicMaterial color="#00d4aa" transparent opacity={0.8} />
|
||||||
|
</mesh>
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── User Device (Step 3) ─── */
|
||||||
|
function UserDevice({ active }: { active: boolean }) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const beamRef = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
const targetScale = active ? 1 : 0;
|
||||||
|
const current = groupRef.current.scale.x;
|
||||||
|
const next = THREE.MathUtils.lerp(current, targetScale, 0.04);
|
||||||
|
groupRef.current.scale.setScalar(next);
|
||||||
|
|
||||||
|
if (beamRef.current) {
|
||||||
|
const t = clock.getElapsedTime();
|
||||||
|
const mat = beamRef.current.material as THREE.MeshBasicMaterial;
|
||||||
|
mat.opacity = active ? 0.1 + 0.05 * Math.sin(t * 3) : 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef} position={[0, -3.5, 1.5]} scale={0}>
|
||||||
|
<Float speed={2} rotationIntensity={0} floatIntensity={0.3}>
|
||||||
|
{/* Phone shape */}
|
||||||
|
<mesh>
|
||||||
|
<boxGeometry args={[0.35, 0.6, 0.03]} />
|
||||||
|
<meshBasicMaterial color="#ffffff" transparent opacity={0.15} />
|
||||||
|
</mesh>
|
||||||
|
{/* Screen */}
|
||||||
|
<mesh position={[0, 0, 0.02]}>
|
||||||
|
<planeGeometry args={[0.28, 0.48]} />
|
||||||
|
<meshBasicMaterial color="#00d4aa" transparent opacity={0.3} />
|
||||||
|
</mesh>
|
||||||
|
</Float>
|
||||||
|
{/* Connection beam to network */}
|
||||||
|
<mesh ref={beamRef} position={[0, 1.5, 0]}>
|
||||||
|
<cylinderGeometry args={[0.005, 0.05, 3, 8]} />
|
||||||
|
<meshBasicMaterial color="#00d4aa" transparent opacity={0} />
|
||||||
|
</mesh>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Scene ─── */
|
||||||
|
function NetworkScene({ step }: { step: number }) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
const nodes = useMemo(() => generateNodes(NODE_COUNT, SPHERE_RADIUS), []);
|
||||||
|
const edges = useMemo(() => generateEdges(nodes, EDGE_MAX_DIST), [nodes]);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
groupRef.current.rotation.y = clock.getElapsedTime() * 0.05;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
{/* Edges */}
|
||||||
|
<NetworkEdges nodes={nodes} edges={edges} active={step >= 0} />
|
||||||
|
|
||||||
|
{/* Nodes */}
|
||||||
|
{nodes.map((node, i) => (
|
||||||
|
<GlowNode
|
||||||
|
key={i}
|
||||||
|
node={node}
|
||||||
|
active={step >= 0}
|
||||||
|
highlight={step >= 2 && i % 5 === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Deploy packets (step 2) */}
|
||||||
|
<DeployPackets nodes={nodes} edges={edges} active={step >= 1} />
|
||||||
|
|
||||||
|
{/* User device (step 3) */}
|
||||||
|
<UserDevice active={step >= 2} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Exported Component ─── */
|
||||||
|
export function NetworkVisualization({ step }: { step: number }) {
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="w-full aspect-square max-w-[500px] mx-auto bg-surface-2/30 rounded-lg" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full aspect-square max-w-[500px] mx-auto">
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 0, 7], fov: 50 }}
|
||||||
|
dpr={[1, 1.5]}
|
||||||
|
gl={{ antialias: true, alpha: true }}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
>
|
||||||
|
<ambientLight intensity={0.5} />
|
||||||
|
<NetworkScene step={step} />
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/landing/ops-anyone.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { SpecTable } from "../ui/spec-table";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
const rewardSpec = [
|
||||||
|
{ label: "Reward token", value: "$ORAMA — uptime, bandwidth, compute" },
|
||||||
|
{ label: "Privacy relay", value: "Orama Proxy on every node" },
|
||||||
|
{ label: "Routing", value: "Onion-routed traffic for all requests" },
|
||||||
|
{ label: "Payout", value: "Continuous, based on contribution metrics" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OpsAnyone() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader title="Privacy Built In with Orama Proxy" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12">
|
||||||
|
{/* Left — text */}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Badge variant="accent" className="w-fit">Privacy Layer</Badge>
|
||||||
|
<p className="text-muted leading-relaxed">
|
||||||
|
Every Orama node runs the Orama Proxy privacy relay. As an operator,
|
||||||
|
you earn $ORAMA rewards while providing onion-routed privacy for all network traffic.
|
||||||
|
</p>
|
||||||
|
<SpecTable rows={rewardSpec} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right — visual */}
|
||||||
|
<DashedPanel withCorners withBackground>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-6 p-8">
|
||||||
|
<div className="w-20 h-20 rounded-full border border-dashed border-border flex items-center justify-center">
|
||||||
|
<span className="text-3xl font-bold font-mono text-fg">OP</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-fg font-display">$ORAMA</div>
|
||||||
|
<div className="text-xs font-mono text-muted mt-1">Network rewards</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full border-t border-dashed border-border" />
|
||||||
|
<p className="text-sm text-muted text-center">
|
||||||
|
One node. Privacy built in. Earn $ORAMA from privacy infrastructure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/landing/ops-orama-one.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
export function OpsOramaOne() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section>
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-12">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center text-center gap-4">
|
||||||
|
<Badge variant="status">COMING SOON</Badge>
|
||||||
|
<h2 className="font-display text-3xl lg:text-4xl font-bold text-fg tracking-tight">
|
||||||
|
Orama One
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-accent font-mono tracking-wider">
|
||||||
|
Plug in. Connect. Earn.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device showcase */}
|
||||||
|
<DashedPanel withCorners className="w-full overflow-hidden">
|
||||||
|
<div className="relative flex items-center justify-center py-16 sm:py-24">
|
||||||
|
{/* Ambient glow */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="w-64 h-64 sm:w-96 sm:h-96 rounded-full bg-accent/[0.06] blur-[80px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device silhouette */}
|
||||||
|
<div className="relative w-72 sm:w-96 h-32 sm:h-40">
|
||||||
|
{/* Main body */}
|
||||||
|
<div className="absolute inset-0 bg-bg rounded-xl border border-border/60 shadow-[0_0_60px_rgba(65,105,225,0.1),0_0_120px_rgba(65,105,225,0.05)]" />
|
||||||
|
|
||||||
|
{/* Top edge highlight */}
|
||||||
|
<div className="absolute top-0 left-4 right-4 h-px bg-gradient-to-r from-transparent via-accent/30 to-transparent" />
|
||||||
|
|
||||||
|
{/* LED indicators */}
|
||||||
|
<div className="absolute top-5 left-6 flex gap-3">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-border/60" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-border/60" />
|
||||||
|
<div className="w-2 h-2 rounded-full bg-accent/50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ventilation lines */}
|
||||||
|
<div className="absolute top-5 right-6 flex flex-col gap-1.5">
|
||||||
|
<div className="w-8 h-px bg-border/40" />
|
||||||
|
<div className="w-8 h-px bg-border/40" />
|
||||||
|
<div className="w-8 h-px bg-border/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center wordmark */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-sm sm:text-base font-mono text-muted/20 tracking-[0.4em] uppercase">
|
||||||
|
Orama One
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom ports */}
|
||||||
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-4">
|
||||||
|
<div className="w-6 h-2 rounded-sm border border-border/40" />
|
||||||
|
<div className="w-6 h-2 rounded-sm border border-border/40" />
|
||||||
|
<div className="w-3 h-2 rounded-full border border-border/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Base stand */}
|
||||||
|
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 w-48 sm:w-64 h-1 bg-border/20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashedPanel>
|
||||||
|
|
||||||
|
{/* Specs */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-4xl mx-auto w-full">
|
||||||
|
<DashedPanel className="p-4 text-center">
|
||||||
|
<span className="text-xs font-mono text-muted block mb-1">FORM FACTOR</span>
|
||||||
|
<span className="text-sm text-fg">Compact. Silent. Always-on.</span>
|
||||||
|
</DashedPanel>
|
||||||
|
<DashedPanel className="p-4 text-center">
|
||||||
|
<span className="text-xs font-mono text-muted block mb-1">CONNECTIVITY</span>
|
||||||
|
<span className="text-sm text-fg">Ethernet + WiFi + WireGuard</span>
|
||||||
|
</DashedPanel>
|
||||||
|
<DashedPanel className="p-4 text-center">
|
||||||
|
<span className="text-xs font-mono text-muted block mb-1">SETUP</span>
|
||||||
|
<span className="text-sm text-fg">Plug in and start earning</span>
|
||||||
|
</DashedPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description + CTA */}
|
||||||
|
<div className="flex flex-col items-center text-center gap-6 max-w-2xl mx-auto">
|
||||||
|
<p className="text-muted leading-relaxed">
|
||||||
|
A pre-built hardware node. No VPS. No terminal. No configuration.
|
||||||
|
Just plug it in, connect to the network, and start earning $ORAMA.
|
||||||
|
</p>
|
||||||
|
<Button variant="ghost" size="lg">
|
||||||
|
Notify Me When Available
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs font-mono text-muted">
|
||||||
|
Expected Q2 2026
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/landing/ops-setup.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { Terminal } from "../ui/terminal";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
|
||||||
|
export function OpsSetup() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section id="setup">
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="From zero to earning in 5 minutes."
|
||||||
|
subtitle="Get your node running with just a VPS and an invite token."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-8 max-w-3xl mx-auto w-full">
|
||||||
|
{/* Step 1 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Badge variant="outline" className="shrink-0 mt-1 font-mono">1</Badge>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-display font-semibold text-fg text-sm mb-1">Get a VPS</h3>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Any Linux VPS with 4GB RAM, 2 cores, 40GB disk. Hetzner, DigitalOcean, Vultr — any provider works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Badge variant="outline" className="shrink-0 mt-1 font-mono">2</Badge>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-display font-semibold text-fg text-sm mb-2">Install the Orama node</h3>
|
||||||
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ prefix: "$", text: "brew install DeBrosOfficial/tap/orama" },
|
||||||
|
{ prefix: "\u2192", text: "Installing Orama CLI... done" },
|
||||||
|
{ text: "" },
|
||||||
|
{ prefix: "$", text: "sudo orama node install --vps-ip <IP> --domain <domain> --token <invite>" },
|
||||||
|
{ prefix: "\u2192", text: "Downloading services... done" },
|
||||||
|
{ prefix: "\u2192", text: "Configuring WireGuard mesh..." },
|
||||||
|
{ prefix: "\u2192", text: "Starting RQLite, Olric, Gateway..." },
|
||||||
|
{ prefix: "\u2713", text: "Node is live and connected to the mesh" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 3 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Badge variant="outline" className="shrink-0 mt-1 font-mono">3</Badge>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-display font-semibold text-fg text-sm mb-2">Verify and start earning</h3>
|
||||||
|
<Terminal
|
||||||
|
lines={[
|
||||||
|
{ prefix: "$", text: "orama node status" },
|
||||||
|
{ prefix: "\u2192", text: "Node: online" },
|
||||||
|
{ prefix: "\u2192", text: "Cluster: connected (3/3 peers)" },
|
||||||
|
{ prefix: "\u2713", text: "Rewards: accumulating" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Button asChild variant="ghost">
|
||||||
|
<Link to="/docs/operator/node-setup">
|
||||||
|
Read the full setup guide →
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/components/landing/ops-tokenomics.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
|
import { Coins, Vote, CreditCard, Server, ArrowRight } from "lucide-react";
|
||||||
|
import { Section } from "../layout/section";
|
||||||
|
import { SectionHeader } from "../ui/section-header";
|
||||||
|
import { MetricCard } from "../ui/metric-card";
|
||||||
|
import { DashedPanel } from "../ui/dashed-panel";
|
||||||
|
import { CrosshairDivider } from "../ui/crosshair-divider";
|
||||||
|
import { AnimateIn } from "../ui/animate-in";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
|
||||||
|
const TIER_ACCENT: Record<string, string> = {
|
||||||
|
Base: "#888",
|
||||||
|
Enhanced: "#4169E1",
|
||||||
|
Governor: "#a855f7",
|
||||||
|
};
|
||||||
|
|
||||||
|
const rewardTiers = [
|
||||||
|
{
|
||||||
|
icon: <Server className="w-5 h-5" />,
|
||||||
|
tier: "Base",
|
||||||
|
stake: "***",
|
||||||
|
multiplier: "***",
|
||||||
|
description: "Standard rewards for running a node with minimum stake",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Coins className="w-5 h-5" />,
|
||||||
|
tier: "Enhanced",
|
||||||
|
stake: "***",
|
||||||
|
multiplier: "***",
|
||||||
|
description: "Higher stake unlocks enhanced reward multiplier",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Vote className="w-5 h-5" />,
|
||||||
|
tier: "Governor",
|
||||||
|
stake: "***",
|
||||||
|
multiplier: "***",
|
||||||
|
description: "Top-tier rewards plus governance voting power",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function OpsTokenomics() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section id="tokenomics">
|
||||||
|
<AnimateIn>
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<SectionHeader
|
||||||
|
title="Reward Structure"
|
||||||
|
subtitle="Earn $ORAMA for every request you serve. Higher stake, higher rewards."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Key metrics */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 max-w-4xl mx-auto w-full">
|
||||||
|
<DashedPanel className="p-4">
|
||||||
|
<MetricCard label="Rewards" value="$ORAMA" />
|
||||||
|
</DashedPanel>
|
||||||
|
<DashedPanel className="p-4">
|
||||||
|
<MetricCard label="Payout" value="Daily" />
|
||||||
|
</DashedPanel>
|
||||||
|
<DashedPanel className="p-4">
|
||||||
|
<MetricCard label="Based On" value="Uptime + Traffic" />
|
||||||
|
</DashedPanel>
|
||||||
|
<DashedPanel className="p-4">
|
||||||
|
<MetricCard label="Operators" value="Unlimited" />
|
||||||
|
</DashedPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reward tiers */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{rewardTiers.map((tier) => {
|
||||||
|
const accent = TIER_ACCENT[tier.tier] ?? "#888";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tier.tier}
|
||||||
|
className="group relative border border-dashed border-border p-6 flex flex-col gap-5 transition-all duration-300 hover:border-border/80"
|
||||||
|
style={{ borderLeftColor: accent, borderLeftWidth: 2, borderLeftStyle: "solid" }}
|
||||||
|
>
|
||||||
|
{/* Subtle gradient hover overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${accent}08 0%, transparent 60%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div style={{ color: accent }}>{tier.icon}</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-display font-semibold text-fg">
|
||||||
|
{tier.tier}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted font-mono">Tier</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{tier.tier}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multiplier — large and prominent */}
|
||||||
|
<div className="relative flex items-baseline gap-2">
|
||||||
|
<span
|
||||||
|
className="font-mono text-4xl font-bold tracking-tight"
|
||||||
|
style={{ color: accent }}
|
||||||
|
>
|
||||||
|
<span className="redacted-inline">{tier.multiplier}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted font-mono uppercase tracking-wider">
|
||||||
|
Multiplier
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-baseline gap-1">
|
||||||
|
<span className="font-mono text-lg font-bold text-fg">
|
||||||
|
<span className="redacted-inline">{tier.stake}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted font-mono">$ORAMA staked</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="relative text-sm text-muted leading-relaxed">
|
||||||
|
{tier.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Utility summary + CTA */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 border border-dashed border-border p-5">
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm text-muted">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4 text-accent" />
|
||||||
|
<span>Pay in BTC or $ORAMA</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Vote className="w-4 h-4 text-accent" />
|
||||||
|
<span>Governance voting with stake</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="ghost" size="sm">
|
||||||
|
<Link to="/token" className="flex items-center gap-1.5">
|
||||||
|
Full Tokenomics
|
||||||
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimateIn>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section padding="none">
|
||||||
|
<CrosshairDivider />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||