Watchlist layout fix + TLD detail + Sniper/Yield/Listing redesign + deploy script
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
This commit is contained in:
207
deploy.sh
207
deploy.sh
@ -1,156 +1,79 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
|
||||||
# POUNCE Deployment Script
|
# ============================================================================
|
||||||
# Usage: ./deploy.sh [dev|prod]
|
# POUNCE DEPLOY SCRIPT
|
||||||
#
|
# Commits all changes and deploys to server
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
MODE=${1:-dev}
|
|
||||||
echo "================================================"
|
|
||||||
echo " POUNCE Deployment - Mode: $MODE"
|
|
||||||
echo "================================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
YELLOW='\033[1;33m'
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
RED='\033[0;31m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# Functions
|
# Server config
|
||||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
SERVER_USER="user"
|
||||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
SERVER_HOST="10.42.0.73"
|
||||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
SERVER_PATH="/home/user/pounce"
|
||||||
|
SERVER_PASS="user"
|
||||||
|
|
||||||
# ============================================
|
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
# 1. Check prerequisites
|
echo -e "${BLUE} POUNCE DEPLOY SCRIPT ${NC}"
|
||||||
# ============================================
|
echo -e "${BLUE}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
log_info "Checking prerequisites..."
|
|
||||||
|
|
||||||
if ! command -v python3 &> /dev/null; then
|
# Get commit message
|
||||||
log_error "Python3 not found. Please install Python 3.10+"
|
if [ -z "$1" ]; then
|
||||||
exit 1
|
COMMIT_MSG="Deploy: $(date '+%Y-%m-%d %H:%M')"
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v node &> /dev/null; then
|
|
||||||
log_error "Node.js not found. Please install Node.js 18+"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v npm &> /dev/null; then
|
|
||||||
log_error "npm not found. Please install npm"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log_info "Prerequisites OK!"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# 2. Setup Backend
|
|
||||||
# ============================================
|
|
||||||
log_info "Setting up Backend..."
|
|
||||||
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Create virtual environment if not exists
|
|
||||||
if [ ! -d "venv" ]; then
|
|
||||||
log_info "Creating Python virtual environment..."
|
|
||||||
python3 -m venv venv
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Activate venv
|
|
||||||
source venv/bin/activate
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
log_info "Installing Python dependencies..."
|
|
||||||
pip install -q --upgrade pip
|
|
||||||
pip install -q -r requirements.txt
|
|
||||||
|
|
||||||
# Create .env if not exists
|
|
||||||
if [ ! -f ".env" ]; then
|
|
||||||
log_warn ".env file not found, copying from env.example..."
|
|
||||||
if [ -f "env.example" ]; then
|
|
||||||
cp env.example .env
|
|
||||||
log_warn "Please edit backend/.env with your settings!"
|
|
||||||
else
|
|
||||||
log_error "env.example not found!"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Initialize database
|
|
||||||
log_info "Initializing database..."
|
|
||||||
python scripts/init_db.py
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
log_info "Backend setup complete!"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# 3. Setup Frontend
|
|
||||||
# ============================================
|
|
||||||
log_info "Setting up Frontend..."
|
|
||||||
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
log_info "Installing npm dependencies..."
|
|
||||||
npm install --silent
|
|
||||||
|
|
||||||
# Create .env.local if not exists
|
|
||||||
if [ ! -f ".env.local" ]; then
|
|
||||||
log_warn ".env.local not found, creating..."
|
|
||||||
echo "NEXT_PUBLIC_API_URL=http://localhost:8000" > .env.local
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
if [ "$MODE" == "prod" ]; then
|
|
||||||
log_info "Building for production..."
|
|
||||||
npm run build
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd ..
|
|
||||||
log_info "Frontend setup complete!"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# 4. Start Services
|
|
||||||
# ============================================
|
|
||||||
if [ "$MODE" == "dev" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "================================================"
|
|
||||||
echo " Development Setup Complete!"
|
|
||||||
echo "================================================"
|
|
||||||
echo ""
|
|
||||||
echo "To start the services:"
|
|
||||||
echo ""
|
|
||||||
echo " Backend (Terminal 1):"
|
|
||||||
echo " cd backend && source venv/bin/activate"
|
|
||||||
echo " uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
|
|
||||||
echo ""
|
|
||||||
echo " Frontend (Terminal 2):"
|
|
||||||
echo " cd frontend && npm run dev"
|
|
||||||
echo ""
|
|
||||||
echo "Then open: http://localhost:3000"
|
|
||||||
echo ""
|
|
||||||
else
|
else
|
||||||
echo ""
|
COMMIT_MSG="$1"
|
||||||
echo "================================================"
|
|
||||||
echo " Production Setup Complete!"
|
|
||||||
echo "================================================"
|
|
||||||
echo ""
|
|
||||||
echo "To start with PM2:"
|
|
||||||
echo ""
|
|
||||||
echo " # Backend"
|
|
||||||
echo " cd backend && source venv/bin/activate"
|
|
||||||
echo " pm2 start 'uvicorn app.main:app --host 0.0.0.0 --port 8000' --name pounce-backend"
|
|
||||||
echo ""
|
|
||||||
echo " # Frontend"
|
|
||||||
echo " cd frontend"
|
|
||||||
echo " pm2 start 'npm start' --name pounce-frontend"
|
|
||||||
echo ""
|
|
||||||
echo "Or use Docker:"
|
|
||||||
echo " docker-compose up -d"
|
|
||||||
echo ""
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[1/5] Staging changes...${NC}"
|
||||||
|
git add -A
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[2/5] Committing: ${COMMIT_MSG}${NC}"
|
||||||
|
git commit -m "$COMMIT_MSG" || echo "Nothing to commit"
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[3/5] Pushing to git.6bit.ch...${NC}"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[4/5] Syncing files to server...${NC}"
|
||||||
|
# Sync frontend
|
||||||
|
sshpass -p "$SERVER_PASS" rsync -avz --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.next' \
|
||||||
|
--exclude '.git' \
|
||||||
|
frontend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/frontend/
|
||||||
|
|
||||||
|
# Sync backend
|
||||||
|
sshpass -p "$SERVER_PASS" rsync -avz --delete \
|
||||||
|
--exclude '__pycache__' \
|
||||||
|
--exclude '.pytest_cache' \
|
||||||
|
--exclude 'venv' \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude '*.pyc' \
|
||||||
|
backend/ $SERVER_USER@$SERVER_HOST:$SERVER_PATH/backend/
|
||||||
|
|
||||||
|
echo -e "\n${YELLOW}[5/5] Building and restarting on server...${NC}"
|
||||||
|
sshpass -p "$SERVER_PASS" ssh $SERVER_USER@$SERVER_HOST << 'EOF'
|
||||||
|
cd ~/pounce
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
echo "Building frontend..."
|
||||||
|
cd frontend
|
||||||
|
npm run build 2>&1 | tail -10
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
echo "Restarting services..."
|
||||||
|
./start.sh 2>&1 | tail -5
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo -e "\n${GREEN}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN} ✅ DEPLOY COMPLETE! ${NC}"
|
||||||
|
echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "\nFrontend: ${BLUE}http://$SERVER_HOST:3000${NC}"
|
||||||
|
echo -e "Backend: ${BLUE}http://$SERVER_HOST:8000${NC}"
|
||||||
|
|||||||
@ -102,18 +102,11 @@ function LoginForm() {
|
|||||||
try {
|
try {
|
||||||
await login(email, password)
|
await login(email, password)
|
||||||
|
|
||||||
// Check if email is verified
|
|
||||||
const user = await api.getMe()
|
|
||||||
if (!user.is_verified) {
|
|
||||||
// Redirect to verify-email page if not verified
|
|
||||||
router.push(`/verify-email?email=${encodeURIComponent(email)}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear stored redirect (was set during registration)
|
// Clear stored redirect (was set during registration)
|
||||||
localStorage.removeItem('pounce_redirect_after_login')
|
localStorage.removeItem('pounce_redirect_after_login')
|
||||||
|
|
||||||
// Redirect to intended destination or dashboard
|
// Redirect to intended destination or dashboard
|
||||||
|
// Note: Email verification is enforced by the backend if REQUIRE_EMAIL_VERIFICATION=true
|
||||||
router.push(sanitizeRedirect(redirectTo))
|
router.push(sanitizeRedirect(redirectTo))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Login error:', err)
|
console.error('Login error:', err)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Target,
|
Target,
|
||||||
@ -22,10 +22,20 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Hash,
|
Hash,
|
||||||
Crown,
|
Crown,
|
||||||
Activity
|
Eye,
|
||||||
|
Gavel,
|
||||||
|
TrendingUp,
|
||||||
|
Menu,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
LogOut,
|
||||||
|
Sparkles,
|
||||||
|
Coins,
|
||||||
|
Tag
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// INTERFACES
|
// INTERFACES
|
||||||
@ -62,13 +72,16 @@ interface SniperAlert {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default function SniperAlertsPage() {
|
export default function SniperAlertsPage() {
|
||||||
const { subscription } = useStore()
|
const { subscription, user, logout, checkAuth } = useStore()
|
||||||
const [alerts, setAlerts] = useState<SniperAlert[]>([])
|
const [alerts, setAlerts] = useState<SniperAlert[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [editingAlert, setEditingAlert] = useState<SniperAlert | null>(null)
|
const [editingAlert, setEditingAlert] = useState<SniperAlert | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Mobile Menu
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
const tier = subscription?.tier || 'scout'
|
const tier = subscription?.tier || 'scout'
|
||||||
const alertLimits: Record<string, number> = { scout: 2, trader: 10, tycoon: 50 }
|
const alertLimits: Record<string, number> = { scout: 2, trader: 10, tycoon: 50 }
|
||||||
@ -80,6 +93,10 @@ export default function SniperAlertsPage() {
|
|||||||
const totalMatches = alerts.reduce((sum, a) => sum + a.matches_count, 0)
|
const totalMatches = alerts.reduce((sum, a) => sum + a.matches_count, 0)
|
||||||
const totalNotifications = alerts.reduce((sum, a) => sum + a.notifications_sent, 0)
|
const totalNotifications = alerts.reduce((sum, a) => sum + a.notifications_sent, 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth()
|
||||||
|
}, [checkAuth])
|
||||||
|
|
||||||
const loadAlerts = useCallback(async () => {
|
const loadAlerts = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@ -125,277 +142,309 @@ export default function SniperAlertsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Mobile Nav
|
||||||
<CommandCenterLayout minimal>
|
const mobileNavItems = [
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{ href: '/terminal/radar', label: 'Radar', icon: Target, active: false },
|
||||||
{/* HEADER */}
|
{ href: '/terminal/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
<section className="pt-6 lg:pt-8 pb-6">
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
|
]
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="inline-flex items-center gap-2">
|
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||||
<Target className="w-4 h-4 text-accent" />
|
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||||
<span className="text-[10px] font-mono tracking-wide text-accent">Automated Alerts</span>
|
|
||||||
</div>
|
const drawerNavSections = [
|
||||||
|
{ title: 'Discover', items: [
|
||||||
<h1 className="font-display text-[2rem] lg:text-[2.5rem] leading-[1] tracking-[-0.02em]">
|
{ href: '/terminal/radar', label: 'Radar', icon: Target },
|
||||||
<span className="text-white">Sniper</span>
|
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
||||||
<span className="text-white/30 ml-3">{alerts.length}/{maxAlerts}</span>
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
</h1>
|
]},
|
||||||
|
{ title: 'Manage', items: [
|
||||||
<p className="text-white/40 text-sm max-w-md">
|
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye },
|
||||||
Get notified when domains matching your criteria hit the market
|
{ href: '/terminal/sniper', label: 'Sniper', icon: Target, active: true },
|
||||||
</p>
|
]},
|
||||||
</div>
|
{ title: 'Monetize', items: [
|
||||||
|
{ href: '/terminal/yield', label: 'Yield', icon: Coins, isNew: true },
|
||||||
<div className="flex items-center gap-4">
|
{ href: '/terminal/listing', label: 'For Sale', icon: Tag },
|
||||||
<div className="flex gap-6">
|
]}
|
||||||
<div className="text-right">
|
]
|
||||||
<div className="text-xl font-display text-accent">{activeAlerts}</div>
|
|
||||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Active</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-display text-white">{totalMatches}</div>
|
|
||||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Matches</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-display text-white">{totalNotifications}</div>
|
|
||||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Sent</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
disabled={!canAddMore}
|
|
||||||
className={clsx(
|
|
||||||
"flex items-center gap-2 px-4 py-2 text-sm font-semibold transition-colors",
|
|
||||||
canAddMore
|
|
||||||
? "bg-accent text-black hover:bg-white"
|
|
||||||
: "bg-white/10 text-white/40 cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
New Alert
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
return (
|
||||||
{/* ALERTS LIST */}
|
<div className="min-h-screen bg-[#020202]">
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
<div className="hidden lg:block"><Sidebar /></div>
|
||||||
<section className="py-6 border-t border-white/[0.08]">
|
|
||||||
{loading ? (
|
<main className="lg:pl-[240px]">
|
||||||
<div className="flex items-center justify-center py-20">
|
{/* MOBILE HEADER */}
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
<header className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
|
||||||
</div>
|
<div className="px-4 py-3">
|
||||||
) : alerts.length === 0 ? (
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="text-center py-16">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-14 h-14 mx-auto bg-white/[0.02] border border-white/10 flex items-center justify-center mb-4">
|
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||||
<Target className="w-6 h-6 text-white/20" />
|
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Sniper Alerts</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-white/40">{alerts.length}/{maxAlerts}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
||||||
|
<div className="text-lg font-bold text-accent tabular-nums">{activeAlerts}</div>
|
||||||
|
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Active</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||||
|
<div className="text-lg font-bold text-white tabular-nums">{totalMatches}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Matches</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||||
|
<div className="text-lg font-bold text-white tabular-nums">{totalNotifications}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Sent</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/40 text-sm mb-2">No alerts yet</p>
|
|
||||||
<p className="text-white/25 text-xs mb-6 max-w-sm mx-auto">
|
|
||||||
Create your first sniper alert to get notified when matching domains appear
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-sm font-semibold hover:bg-white transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Create First Alert
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</header>
|
||||||
<div className="space-y-3">
|
|
||||||
{alerts.map((alert) => (
|
{/* DESKTOP HEADER */}
|
||||||
<div
|
<section className="hidden lg:block px-10 pt-10 pb-6">
|
||||||
key={alert.id}
|
<div className="flex items-end justify-between gap-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||||
|
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Automated Alerts</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em]">
|
||||||
|
<span className="text-white">Sniper</span>
|
||||||
|
<span className="text-white/30 ml-3 font-mono text-[2rem]">{alerts.length}/{maxAlerts}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex gap-8">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-accent font-mono">{activeAlerts}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Active</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-white font-mono">{totalMatches}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Matches</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
disabled={!canAddMore}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"group border transition-all",
|
"flex items-center gap-2 px-4 py-2.5 text-xs font-bold uppercase tracking-wider transition-colors",
|
||||||
alert.is_active
|
canAddMore ? "bg-accent text-black hover:bg-white" : "bg-white/10 text-white/40 cursor-not-allowed"
|
||||||
? "bg-white/[0.02] border-white/[0.08] hover:border-accent/30"
|
|
||||||
: "bg-white/[0.01] border-white/[0.04]"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="p-5">
|
<Plus className="w-4 h-4" />
|
||||||
|
New Alert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ADD BUTTON MOBILE */}
|
||||||
|
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
disabled={!canAddMore}
|
||||||
|
className={clsx(
|
||||||
|
"w-full flex items-center justify-center gap-2 py-3 text-xs font-bold uppercase tracking-wider transition-colors",
|
||||||
|
canAddMore ? "bg-accent text-black" : "bg-white/10 text-white/40"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Alert
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ALERTS LIST */}
|
||||||
|
<section className="px-4 lg:px-10 py-4 pb-28 lg:pb-10">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : alerts.length === 0 ? (
|
||||||
|
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||||
|
<Target className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||||
|
<p className="text-white/40 text-sm font-mono mb-2">No alerts yet</p>
|
||||||
|
<p className="text-white/25 text-xs font-mono">Create your first sniper alert</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div key={alert.id} className="bg-[#020202] hover:bg-white/[0.02] transition-all p-4">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
<h3 className="text-base font-medium text-white truncate">{alert.name}</h3>
|
<h3 className="text-sm font-bold text-white font-mono">{alert.name}</h3>
|
||||||
{alert.is_active ? (
|
{alert.is_active ? (
|
||||||
<span className="px-2 py-0.5 text-[9px] font-mono bg-accent/10 text-accent border border-accent/20 flex items-center gap-1">
|
<span className="px-1.5 py-0.5 text-[9px] font-mono bg-accent/10 text-accent border border-accent/20 flex items-center gap-1">
|
||||||
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
<div className="w-1 h-1 bg-accent animate-pulse" />
|
||||||
Active
|
Active
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-0.5 text-[9px] font-mono bg-white/5 text-white/40 border border-white/10">
|
<span className="px-1.5 py-0.5 text-[9px] font-mono bg-white/5 text-white/40 border border-white/10">Paused</span>
|
||||||
Paused
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{isTycoon && alert.notify_sms && (
|
{isTycoon && alert.notify_sms && (
|
||||||
<span className="px-2 py-0.5 text-[9px] font-mono bg-amber-400/10 text-amber-400 border border-amber-400/20 flex items-center gap-1">
|
<span className="px-1.5 py-0.5 text-[9px] font-mono bg-amber-400/10 text-amber-400 border border-amber-400/20 flex items-center gap-1">
|
||||||
<Crown className="w-3 h-3" />
|
<Crown className="w-3 h-3" />SMS
|
||||||
SMS
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{alert.description && (
|
|
||||||
<p className="text-sm text-white/40 mb-3">{alert.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
{alert.tlds && (
|
{alert.tlds && <span className="px-1.5 py-0.5 text-[9px] font-mono bg-blue-400/10 text-blue-400 border border-blue-400/20">{alert.tlds}</span>}
|
||||||
<span className="px-2 py-0.5 text-[10px] font-mono bg-blue-400/10 text-blue-400 border border-blue-400/20">
|
{alert.keywords && <span className="px-1.5 py-0.5 text-[9px] font-mono bg-accent/10 text-accent border border-accent/20">+{alert.keywords}</span>}
|
||||||
{alert.tlds}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{alert.keywords && (
|
|
||||||
<span className="px-2 py-0.5 text-[10px] font-mono bg-accent/10 text-accent border border-accent/20">
|
|
||||||
+{alert.keywords}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{alert.exclude_keywords && (
|
|
||||||
<span className="px-2 py-0.5 text-[10px] font-mono bg-rose-400/10 text-rose-400 border border-rose-400/20">
|
|
||||||
-{alert.exclude_keywords}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(alert.min_length || alert.max_length) && (
|
|
||||||
<span className="px-2 py-0.5 text-[10px] font-mono bg-white/5 text-white/50 border border-white/10 flex items-center gap-1">
|
|
||||||
<Hash className="w-3 h-3" />
|
|
||||||
{alert.min_length || 1}-{alert.max_length || 63}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{(alert.min_price || alert.max_price) && (
|
{(alert.min_price || alert.max_price) && (
|
||||||
<span className="px-2 py-0.5 text-[10px] font-mono bg-white/5 text-white/50 border border-white/10 flex items-center gap-1">
|
<span className="px-1.5 py-0.5 text-[9px] font-mono bg-white/5 text-white/50 border border-white/10 flex items-center gap-0.5">
|
||||||
<DollarSign className="w-3 h-3" />
|
<DollarSign className="w-3 h-3" />
|
||||||
{alert.min_price ? `$${alert.min_price}` : ''}{alert.max_price ? ` - $${alert.max_price}` : '+'}
|
{alert.min_price || 0}-{alert.max_price || '∞'}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{alert.no_numbers && (
|
|
||||||
<span className="px-2 py-0.5 text-[10px] font-mono bg-white/5 text-white/50 border border-white/10">
|
|
||||||
No digits
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{alert.no_hyphens && (
|
|
||||||
<span className="px-2 py-0.5 text-[10px] font-mono bg-white/5 text-white/50 border border-white/10">
|
|
||||||
No hyphens
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 text-[10px] text-white/30 font-mono">
|
<div className="flex items-center gap-3 text-[10px] text-white/30 font-mono">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1"><Zap className="w-3 h-3" />{alert.matches_count}</span>
|
||||||
<Zap className="w-3 h-3" />
|
<span className="flex items-center gap-1"><Bell className="w-3 h-3" />{alert.notifications_sent}</span>
|
||||||
{alert.matches_count} matches
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Bell className="w-3 h-3" />
|
|
||||||
{alert.notifications_sent} sent
|
|
||||||
</span>
|
|
||||||
{alert.last_matched_at && (
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{new Date(alert.last_matched_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(alert.id, alert.is_active)}
|
onClick={() => handleToggle(alert.id, alert.is_active)}
|
||||||
disabled={togglingId === alert.id}
|
disabled={togglingId === alert.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-8 h-8 flex items-center justify-center border transition-colors",
|
"w-8 h-8 flex items-center justify-center border transition-colors",
|
||||||
alert.is_active
|
alert.is_active ? "bg-accent/10 border-accent/20 text-accent" : "bg-white/5 border-white/10 text-white/40"
|
||||||
? "bg-accent/10 border-accent/20 text-accent hover:bg-accent/20"
|
|
||||||
: "bg-white/5 border-white/10 text-white/40 hover:bg-white/10"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{togglingId === alert.id ? (
|
{togglingId === alert.id ? <Loader2 className="w-4 h-4 animate-spin" /> : alert.is_active ? <Power className="w-4 h-4" /> : <PowerOff className="w-4 h-4" />}
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : alert.is_active ? (
|
|
||||||
<Power className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<PowerOff className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setEditingAlert(alert)} className="w-8 h-8 flex items-center justify-center border bg-white/5 border-white/10 text-white/40 hover:text-white">
|
||||||
<button
|
|
||||||
onClick={() => setEditingAlert(alert)}
|
|
||||||
className="w-8 h-8 flex items-center justify-center border bg-white/5 border-white/10 text-white/40 hover:text-white hover:bg-white/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(alert.id, alert.name)}
|
onClick={() => handleDelete(alert.id, alert.name)}
|
||||||
disabled={deletingId === alert.id}
|
disabled={deletingId === alert.id}
|
||||||
className="w-8 h-8 flex items-center justify-center border bg-white/5 border-white/10 text-white/40 hover:text-rose-400 hover:border-rose-400/30 transition-colors"
|
className="w-8 h-8 flex items-center justify-center border bg-white/5 border-white/10 text-white/40 hover:text-rose-400"
|
||||||
>
|
>
|
||||||
{deletingId === alert.id ? (
|
{deletingId === alert.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{!canAddMore && (
|
||||||
{/* UPGRADE CTA */}
|
<div className="mt-6 p-6 border border-white/[0.08] bg-white/[0.02] text-center">
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
<Crown className="w-6 h-6 text-amber-400 mx-auto mb-3" />
|
||||||
{!canAddMore && (
|
<h3 className="text-sm font-bold text-white mb-1">Alert Limit Reached</h3>
|
||||||
<section className="py-8 border-t border-white/[0.06]">
|
<p className="text-xs font-mono text-white/40 mb-4">Upgrade for more alerts</p>
|
||||||
<div className="text-center max-w-md mx-auto">
|
<Link href="/pricing" className="inline-flex items-center gap-2 px-4 py-2 bg-amber-400 text-black text-xs font-bold uppercase tracking-wider">
|
||||||
<Crown className="w-8 h-8 text-amber-400 mx-auto mb-4" />
|
<Sparkles className="w-3 h-3" />Upgrade
|
||||||
<h3 className="font-display text-xl text-white mb-2">Alert Limit Reached</h3>
|
</Link>
|
||||||
<p className="text-sm text-white/40 mb-4">
|
</div>
|
||||||
You've created {maxAlerts} alerts. Upgrade for more.
|
)}
|
||||||
</p>
|
</section>
|
||||||
<div className="flex gap-4 justify-center text-xs mb-6">
|
|
||||||
<div className="px-3 py-2 bg-white/5 border border-white/10">
|
{/* MOBILE BOTTOM NAV */}
|
||||||
<span className="text-white/40">Trader:</span> <span className="text-white font-medium">10 alerts</span>
|
<nav className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#020202] border-t border-white/[0.08]" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
|
<div className="flex items-stretch h-14">
|
||||||
|
{mobileNavItems.map((item) => (
|
||||||
|
<Link key={item.href} href={item.href} className={clsx("flex-1 flex flex-col items-center justify-center gap-0.5 relative", item.active ? "text-accent" : "text-white/40")}>
|
||||||
|
{item.active && <div className="absolute top-0 left-1/2 -translate-x-1/2 w-10 h-0.5 bg-accent" />}
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="text-[9px] font-mono uppercase tracking-wider">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setMenuOpen(true)} className="flex-1 flex flex-col items-center justify-center gap-0.5 text-white/40">
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
<span className="text-[9px] font-mono uppercase tracking-wider">Menu</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* DRAWER */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-[100]">
|
||||||
|
<div className="absolute inset-0 bg-black/80" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute top-0 right-0 bottom-0 w-[80%] max-w-[300px] bg-[#0A0A0A] border-l border-white/[0.08] flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image src="/pounce-puma.png" alt="Pounce" width={28} height={28} className="object-contain" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-white">POUNCE</h2>
|
||||||
|
<p className="text-[9px] text-white/40 font-mono uppercase">Terminal v1.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setMenuOpen(false)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/60">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-2 bg-amber-400/10 border border-amber-400/20">
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
<span className="text-white/40">Tycoon:</span> <span className="text-amber-400 font-medium">50 + SMS</span>
|
{drawerNavSections.map((section) => (
|
||||||
|
<div key={section.title} className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 px-4 mb-2">
|
||||||
|
<div className="w-1 h-3 bg-accent" />
|
||||||
|
<span className="text-[9px] font-bold text-white/30 uppercase tracking-[0.2em]">{section.title}</span>
|
||||||
|
</div>
|
||||||
|
{section.items.map((item: any) => (
|
||||||
|
<Link key={item.href} href={item.href} onClick={() => setMenuOpen(false)} className={clsx("flex items-center gap-3 px-4 py-2.5 border-l-2 border-transparent", item.active ? "text-accent border-accent bg-white/[0.02]" : "text-white/60")}>
|
||||||
|
<item.icon className="w-4 h-4 text-white/30" />
|
||||||
|
<span className="text-sm font-medium flex-1">{item.label}</span>
|
||||||
|
{item.isNew && <span className="px-1.5 py-0.5 text-[8px] font-bold bg-accent text-black">NEW</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="pt-3 border-t border-white/[0.08] mx-4">
|
||||||
|
<Link href="/terminal/settings" onClick={() => setMenuOpen(false)} className="flex items-center gap-3 py-2.5 text-white/50">
|
||||||
|
<Settings className="w-4 h-4" /><span className="text-sm">Settings</span>
|
||||||
|
</Link>
|
||||||
|
{user?.is_admin && (
|
||||||
|
<Link href="/admin" onClick={() => setMenuOpen(false)} className="flex items-center gap-3 py-2.5 text-amber-500/70">
|
||||||
|
<Shield className="w-4 h-4" /><span className="text-sm">Admin</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center">
|
||||||
|
<TierIcon className="w-4 h-4 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p>
|
||||||
|
<p className="text-[9px] font-mono text-white/40 uppercase">{tierName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{tierName === 'Scout' && (
|
||||||
|
<Link href="/pricing" onClick={() => setMenuOpen(false)} className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase mb-2">
|
||||||
|
<Sparkles className="w-3 h-3" />Upgrade
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { logout(); setMenuOpen(false) }} className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase">
|
||||||
|
<LogOut className="w-3 h-3" />Sign out
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-amber-400 text-black font-semibold hover:bg-amber-300 transition-colors"
|
|
||||||
>
|
|
||||||
Upgrade Now
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
)}
|
</main>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* MODALS */}
|
||||||
{(showCreateModal || editingAlert) && (
|
{(showCreateModal || editingAlert) && (
|
||||||
<CreateEditModal
|
<CreateEditModal
|
||||||
alert={editingAlert}
|
alert={editingAlert}
|
||||||
onClose={() => {
|
onClose={() => { setShowCreateModal(false); setEditingAlert(null) }}
|
||||||
setShowCreateModal(false)
|
onSuccess={() => { loadAlerts(); setShowCreateModal(false); setEditingAlert(null) }}
|
||||||
setEditingAlert(null)
|
|
||||||
}}
|
|
||||||
onSuccess={() => {
|
|
||||||
loadAlerts()
|
|
||||||
setShowCreateModal(false)
|
|
||||||
setEditingAlert(null)
|
|
||||||
}}
|
|
||||||
isTycoon={isTycoon}
|
isTycoon={isTycoon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CommandCenterLayout>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -459,271 +508,96 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
|
|||||||
notify_sms: form.notify_sms && isTycoon,
|
notify_sms: form.notify_sms && isTycoon,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing && alert) {
|
||||||
await api.request(`/sniper-alerts/${alert.id}`, {
|
await api.request(`/sniper-alerts/${alert.id}`, { method: 'PUT', body: JSON.stringify(payload) })
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
await api.request('/sniper-alerts', {
|
await api.request('/sniper-alerts', { method: 'POST', body: JSON.stringify(payload) })
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to save alert')
|
setError(err.message || 'Failed to save')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-[110] bg-black/80 flex items-center justify-center p-4 overflow-y-auto" onClick={onClose}>
|
||||||
className="fixed inset-0 z-[100] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 overflow-y-auto"
|
<div className="w-full max-w-lg bg-[#0A0A0A] border border-white/[0.08] my-8" onClick={(e) => e.stopPropagation()}>
|
||||||
onClick={onClose}
|
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<Target className="w-4 h-4 text-accent" />
|
||||||
className="w-full max-w-2xl bg-[#050505] border border-white/10 my-8"
|
<span className="text-xs font-mono text-accent uppercase tracking-wider">{isEditing ? 'Edit' : 'Create'} Alert</span>
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-6 border-b border-white/[0.08]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Target className="w-5 h-5 text-accent" />
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-white">{isEditing ? 'Edit Alert' : 'Create Sniper Alert'}</h3>
|
|
||||||
<p className="text-xs text-white/40">Set criteria for domain matching</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-white/10 text-white/40 hover:text-white transition-colors">
|
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 max-h-[70vh] overflow-y-auto">
|
<form onSubmit={handleSubmit} className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 flex items-center gap-3 text-rose-400">
|
<div className="p-3 bg-rose-500/10 border border-rose-500/20 flex items-center gap-2 text-rose-400 text-xs">
|
||||||
<AlertCircle className="w-5 h-5" />
|
<AlertCircle className="w-4 h-4" />{error}
|
||||||
<p className="text-sm flex-1">{error}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Basic Info */}
|
<div>
|
||||||
<div className="space-y-4">
|
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Alert Name *</label>
|
||||||
<h4 className="text-xs font-mono text-white/40 tracking-wide">Basic Info</h4>
|
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required
|
||||||
|
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" placeholder="e.g. Premium 4L .com" />
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-xs text-white/50 mb-2">Alert Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
placeholder="e.g. Premium 4L .com domains"
|
|
||||||
required
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-white/50 mb-2">Description</label>
|
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">TLDs</label>
|
||||||
<textarea
|
<input type="text" value={form.tlds} onChange={(e) => setForm({ ...form, tlds: e.target.value })}
|
||||||
value={form.description}
|
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" placeholder="com,io,ai" />
|
||||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
</div>
|
||||||
placeholder="Optional description"
|
<div>
|
||||||
rows={2}
|
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Platforms</label>
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50 transition-all resize-none"
|
<input type="text" value={form.platforms} onChange={(e) => setForm({ ...form, platforms: e.target.value })}
|
||||||
/>
|
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" placeholder="godaddy,sedo" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
<div>
|
||||||
<div className="space-y-4">
|
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Must Contain</label>
|
||||||
<h4 className="text-xs font-mono text-white/40 tracking-wide">Criteria</h4>
|
<input type="text" value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })}
|
||||||
|
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" placeholder="crypto,web3,ai" />
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-white/50 mb-2">TLDs</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.tlds}
|
|
||||||
onChange={(e) => setForm({ ...form, tlds: e.target.value })}
|
|
||||||
placeholder="com,io,ai"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-white/50 mb-2">Platforms</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.platforms}
|
|
||||||
onChange={(e) => setForm({ ...form, platforms: e.target.value })}
|
|
||||||
placeholder="godaddy,sedo"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-white/50 mb-2">Must Contain</label>
|
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Min Price</label>
|
||||||
<input
|
<input type="number" value={form.min_price} onChange={(e) => setForm({ ...form, min_price: e.target.value })} min="0"
|
||||||
type="text"
|
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" placeholder="0" />
|
||||||
value={form.keywords}
|
|
||||||
onChange={(e) => setForm({ ...form, keywords: e.target.value })}
|
|
||||||
placeholder="crypto,web3,ai"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-white/50 mb-2">Must Not Contain</label>
|
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Max Price</label>
|
||||||
<input
|
<input type="number" value={form.max_price} onChange={(e) => setForm({ ...form, max_price: e.target.value })} min="0"
|
||||||
type="text"
|
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" placeholder="10000" />
|
||||||
value={form.exclude_keywords}
|
|
||||||
onChange={(e) => setForm({ ...form, exclude_keywords: e.target.value })}
|
|
||||||
placeholder="xxx,adult"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50 font-mono text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-white/50 mb-2">Min Length</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.min_length}
|
|
||||||
onChange={(e) => setForm({ ...form, min_length: e.target.value })}
|
|
||||||
placeholder="1"
|
|
||||||
min="1"
|
|
||||||
max="63"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-white/50 mb-2">Max Length</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.max_length}
|
|
||||||
onChange={(e) => setForm({ ...form, max_length: e.target.value })}
|
|
||||||
placeholder="63"
|
|
||||||
min="1"
|
|
||||||
max="63"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-white/50 mb-2">Min Price (USD)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.min_price}
|
|
||||||
onChange={(e) => setForm({ ...form, min_price: e.target.value })}
|
|
||||||
placeholder="0"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-white/50 mb-2">Max Price (USD)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={form.max_price}
|
|
||||||
onChange={(e) => setForm({ ...form, max_price: e.target.value })}
|
|
||||||
placeholder="10000"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer p-3 border border-white/[0.06] hover:bg-white/[0.02] transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.no_numbers}
|
|
||||||
onChange={(e) => setForm({ ...form, no_numbers: e.target.checked })}
|
|
||||||
className="w-4 h-4 border-white/20 bg-black text-accent focus:ring-accent focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-white/60">No numbers in domain</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer p-3 border border-white/[0.06] hover:bg-white/[0.02] transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.no_hyphens}
|
|
||||||
onChange={(e) => setForm({ ...form, no_hyphens: e.target.checked })}
|
|
||||||
className="w-4 h-4 border-white/20 bg-black text-accent focus:ring-accent focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-white/60">No hyphens in domain</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notifications */}
|
<div className="space-y-2">
|
||||||
<div className="space-y-4">
|
<label className="flex items-center gap-3 p-2.5 border border-white/[0.06] cursor-pointer hover:bg-white/[0.02]">
|
||||||
<h4 className="text-xs font-mono text-white/40 tracking-wide">Notifications</h4>
|
<input type="checkbox" checked={form.notify_email} onChange={(e) => setForm({ ...form, notify_email: e.target.checked })} className="w-4 h-4" />
|
||||||
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer p-3 border border-white/[0.06] hover:bg-white/[0.02] transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.notify_email}
|
|
||||||
onChange={(e) => setForm({ ...form, notify_email: e.target.checked })}
|
|
||||||
className="w-4 h-4 border-white/20 bg-black text-accent focus:ring-accent focus:ring-offset-0"
|
|
||||||
/>
|
|
||||||
<Bell className="w-4 h-4 text-accent" />
|
<Bell className="w-4 h-4 text-accent" />
|
||||||
<span className="text-sm text-white/60 flex-1">Email notifications</span>
|
<span className="text-sm text-white/60">Email notifications</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label className={clsx("flex items-center gap-3 p-2.5 border cursor-pointer", isTycoon ? "border-amber-400/20 hover:bg-amber-400/[0.02]" : "border-white/[0.06] opacity-50")}>
|
||||||
<label className={clsx(
|
<input type="checkbox" checked={form.notify_sms} onChange={(e) => isTycoon && setForm({ ...form, notify_sms: e.target.checked })} disabled={!isTycoon} className="w-4 h-4" />
|
||||||
"flex items-center gap-3 cursor-pointer p-3 border transition-colors",
|
|
||||||
isTycoon ? "border-amber-400/20 hover:bg-amber-400/[0.02]" : "border-white/[0.06] opacity-50 cursor-not-allowed"
|
|
||||||
)}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.notify_sms}
|
|
||||||
onChange={(e) => isTycoon && setForm({ ...form, notify_sms: e.target.checked })}
|
|
||||||
disabled={!isTycoon}
|
|
||||||
className="w-4 h-4 border-white/20 bg-black text-amber-400 focus:ring-amber-400 focus:ring-offset-0 disabled:opacity-50"
|
|
||||||
/>
|
|
||||||
<MessageSquare className="w-4 h-4 text-amber-400" />
|
<MessageSquare className="w-4 h-4 text-amber-400" />
|
||||||
<span className="text-sm text-white/60 flex-1">SMS notifications</span>
|
<span className="text-sm text-white/60 flex-1">SMS notifications</span>
|
||||||
{!isTycoon && <Crown className="w-4 h-4 text-amber-400" />}
|
{!isTycoon && <Crown className="w-4 h-4 text-amber-400" />}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
<div className="flex gap-3 pt-2">
|
||||||
<div className="flex gap-3 pt-4">
|
<button type="button" onClick={onClose} className="flex-1 py-2.5 border border-white/10 text-white/60 text-xs font-mono uppercase">Cancel</button>
|
||||||
<button
|
<button type="submit" disabled={loading || !form.name} className="flex-1 flex items-center justify-center gap-2 py-2.5 bg-accent text-black text-xs font-bold uppercase disabled:opacity-50">
|
||||||
type="button"
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
|
||||||
onClick={onClose}
|
{isEditing ? 'Update' : 'Create'}
|
||||||
className="flex-1 px-4 py-3 border border-white/10 text-white/60 hover:bg-white/5 hover:text-white transition-all font-medium"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !form.name}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-black font-semibold hover:bg-white transition-all disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle className="w-5 h-5" />
|
|
||||||
{isEditing ? 'Update' : 'Create'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -731,3 +605,4 @@ function CreateEditModal({ alert, onClose, onSuccess, isTycoon }: {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,28 +2,17 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp, DollarSign, Zap, Plus, CheckCircle2, Clock, AlertCircle,
|
||||||
DollarSign,
|
MousePointer, Target, Wallet, RefreshCw, ChevronRight, Copy, Check,
|
||||||
Zap,
|
XCircle, Sparkles, Loader2, Eye, Gavel, Menu, Settings, Shield, LogOut,
|
||||||
Plus,
|
Crown, Coins, Tag, X
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
AlertCircle,
|
|
||||||
MousePointer,
|
|
||||||
Target,
|
|
||||||
Wallet,
|
|
||||||
RefreshCw,
|
|
||||||
ChevronRight,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
XCircle,
|
|
||||||
Sparkles,
|
|
||||||
Loader2
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api, YieldDomain, YieldTransaction } from '@/lib/api'
|
import { api, YieldDomain, YieldTransaction } from '@/lib/api'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
import { Sidebar } from '@/components/Sidebar'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// STATUS BADGE
|
// STATUS BADGE
|
||||||
@ -37,295 +26,63 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
paused: { color: 'bg-white/5 text-white/40 border-white/10', icon: AlertCircle },
|
paused: { color: 'bg-white/5 text-white/40 border-white/10', icon: AlertCircle },
|
||||||
error: { color: 'bg-red-400/10 text-red-400 border-red-400/20', icon: XCircle },
|
error: { color: 'bg-red-400/10 text-red-400 border-red-400/20', icon: XCircle },
|
||||||
}
|
}
|
||||||
|
|
||||||
const { color, icon: Icon } = config[status] || config.pending
|
const { color, icon: Icon } = config[status] || config.pending
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[10px] font-mono border ${color}`}>
|
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-[9px] font-mono border ${color}`}>
|
||||||
<Icon className="w-3 h-3" />
|
<Icon className="w-3 h-3" />{status}
|
||||||
{status}
|
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACTIVATE MODAL
|
// ACTIVATE MODAL (simplified)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function ActivateModal({
|
function ActivateModal({ isOpen, onClose, onSuccess }: { isOpen: boolean; onClose: () => void; onSuccess: () => void }) {
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSuccess
|
|
||||||
}: {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onSuccess: () => void
|
|
||||||
}) {
|
|
||||||
const [step, setStep] = useState<'input' | 'analyze' | 'dns' | 'done'>('input')
|
|
||||||
const [domain, setDomain] = useState('')
|
const [domain, setDomain] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [analysis, setAnalysis] = useState<any>(null)
|
|
||||||
const [dnsInstructions, setDnsInstructions] = useState<any>(null)
|
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleActivate = async () => {
|
||||||
if (!domain.trim()) return
|
if (!domain.trim()) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await api.analyzeYieldDomain(domain.trim())
|
await api.activateYieldDomain(domain.trim(), true)
|
||||||
setAnalysis(result)
|
onSuccess()
|
||||||
setStep('analyze')
|
onClose()
|
||||||
|
setDomain('')
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to analyze domain')
|
setError(err.message || 'Failed')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleActivate = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.activateYieldDomain(domain.trim(), true)
|
|
||||||
setDnsInstructions(result.dns_instructions)
|
|
||||||
setStep('dns')
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err.message || 'Failed to activate domain')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyToClipboard = (text: string, key: string) => {
|
|
||||||
navigator.clipboard.writeText(text)
|
|
||||||
setCopied(key)
|
|
||||||
setTimeout(() => setCopied(null), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDone = () => {
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
setStep('input')
|
|
||||||
setDomain('')
|
|
||||||
setAnalysis(null)
|
|
||||||
setDnsInstructions(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center">
|
<div className="fixed inset-0 z-[110] bg-black/80 flex items-center justify-center p-4" onClick={onClose}>
|
||||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" onClick={onClose} />
|
<div className="w-full max-w-md bg-[#0A0A0A] border border-white/[0.08]" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||||
<div className="relative bg-[#050505] border border-white/10 w-full max-w-lg mx-4">
|
<div className="flex items-center gap-2">
|
||||||
{/* Header */}
|
<Sparkles className="w-4 h-4 text-accent" />
|
||||||
<div className="p-6 border-b border-white/[0.08]">
|
<span className="text-xs font-mono text-accent uppercase tracking-wider">Add Domain</span>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Sparkles className="w-5 h-5 text-accent" />
|
|
||||||
<div>
|
|
||||||
<h2 className="font-medium text-white">Activate Domain for Yield</h2>
|
|
||||||
<p className="text-xs text-white/40">Turn parked domains into passive income</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<button onClick={onClose} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/40"><X className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
{/* Content */}
|
<div>
|
||||||
<div className="p-6">
|
<label className="block text-[9px] font-mono text-white/40 uppercase mb-1.5">Domain</label>
|
||||||
{step === 'input' && (
|
<input type="text" value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="example.com"
|
||||||
<div className="space-y-4">
|
className="w-full px-3 py-2.5 bg-white/5 border border-white/10 text-white text-sm font-mono placeholder:text-white/20 outline-none focus:border-accent/50" />
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-xs text-white/50 mb-2">Domain Name</label>
|
{error && <div className="p-2 bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs">{error}</div>}
|
||||||
<input
|
<button onClick={handleActivate} disabled={loading || !domain.trim()}
|
||||||
type="text"
|
className="w-full py-2.5 bg-accent text-black text-xs font-bold uppercase flex items-center justify-center gap-2 disabled:opacity-50">
|
||||||
value={domain}
|
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
|
||||||
onChange={(e) => setDomain(e.target.value)}
|
Activate
|
||||||
placeholder="e.g. zahnarzt-zuerich.ch"
|
</button>
|
||||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 text-white placeholder:text-white/25 outline-none focus:border-accent/50"
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-400/10 border border-red-400/20 text-red-400 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
disabled={loading || !domain.trim()}
|
|
||||||
className="w-full py-3 px-4 bg-accent text-black font-semibold hover:bg-white disabled:bg-white/10 disabled:text-white/40 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
Analyzing...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
Analyze Intent
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'analyze' && analysis && (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="p-4 bg-white/[0.02] border border-white/[0.08]">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span className="text-xs text-white/40">Detected Intent</span>
|
|
||||||
<span className={clsx(
|
|
||||||
"px-2 py-0.5 text-[10px] font-mono",
|
|
||||||
analysis.intent.confidence > 0.7 ? 'bg-accent/10 text-accent' :
|
|
||||||
analysis.intent.confidence > 0.4 ? 'bg-amber-400/10 text-amber-400' :
|
|
||||||
'bg-white/5 text-white/40'
|
|
||||||
)}>
|
|
||||||
{Math.round(analysis.intent.confidence * 100)}% confidence
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-base font-medium text-white capitalize">
|
|
||||||
{analysis.intent.category.replace('_', ' ')}
|
|
||||||
{analysis.intent.subcategory && (
|
|
||||||
<span className="text-white/40"> / {analysis.intent.subcategory.replace('_', ' ')}</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{analysis.intent.keywords_matched.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
|
||||||
{analysis.intent.keywords_matched.map((kw: string, i: number) => (
|
|
||||||
<span key={i} className="px-2 py-0.5 bg-white/5 text-[10px] font-mono text-white/60">
|
|
||||||
{kw.split('~')[0]}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-accent/5 border border-accent/20">
|
|
||||||
<span className="text-xs text-white/40">Estimated Monthly Revenue</span>
|
|
||||||
<div className="flex items-baseline gap-2 mt-1">
|
|
||||||
<span className="text-2xl font-display text-accent">
|
|
||||||
{analysis.value.currency} {analysis.value.estimated_monthly_min}
|
|
||||||
</span>
|
|
||||||
<span className="text-white/40">-</span>
|
|
||||||
<span className="text-2xl font-display text-accent">
|
|
||||||
{analysis.value.estimated_monthly_max}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between p-3 bg-white/[0.02] border border-white/[0.06]">
|
|
||||||
<span className="text-xs text-white/40">Monetization Potential</span>
|
|
||||||
<span className={clsx(
|
|
||||||
"font-medium text-sm",
|
|
||||||
analysis.monetization_potential === 'high' ? 'text-accent' :
|
|
||||||
analysis.monetization_potential === 'medium' ? 'text-amber-400' :
|
|
||||||
'text-white/40'
|
|
||||||
)}>
|
|
||||||
{analysis.monetization_potential.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-400/10 border border-red-400/20 text-red-400 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setStep('input')}
|
|
||||||
className="flex-1 py-3 px-4 bg-white/5 border border-white/10 text-white/60 hover:bg-white/10 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleActivate}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1 py-3 px-4 bg-accent text-black font-semibold hover:bg-white disabled:bg-white/10 disabled:text-white/40 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
Activating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
|
||||||
Activate
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'dns' && dnsInstructions && (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<div className="w-12 h-12 bg-accent/10 flex items-center justify-center mx-auto mb-3">
|
|
||||||
<CheckCircle2 className="w-6 h-6 text-accent" />
|
|
||||||
</div>
|
|
||||||
<h3 className="font-medium text-white">Domain Registered!</h3>
|
|
||||||
<p className="text-xs text-white/40 mt-1">Complete DNS setup to start earning</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-white/[0.02] border border-white/[0.08]">
|
|
||||||
<h4 className="text-xs font-medium text-white mb-3">Option 1: Nameservers</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{dnsInstructions.nameservers.map((ns: string, i: number) => (
|
|
||||||
<div key={i} className="flex items-center justify-between p-2 bg-black/30">
|
|
||||||
<code className="text-sm text-accent font-mono">{ns}</code>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(ns, `ns-${i}`)}
|
|
||||||
className="p-1 hover:bg-white/10"
|
|
||||||
>
|
|
||||||
{copied === `ns-${i}` ? (
|
|
||||||
<Check className="w-4 h-4 text-accent" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-white/40" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 bg-white/[0.02] border border-white/[0.08]">
|
|
||||||
<h4 className="text-xs font-medium text-white mb-3">Option 2: CNAME Record</h4>
|
|
||||||
<div className="flex items-center justify-between p-2 bg-black/30">
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="text-white/40">Host: </span>
|
|
||||||
<code className="text-white font-mono">{dnsInstructions.cname_host}</code>
|
|
||||||
<span className="text-white/40 mx-2">→</span>
|
|
||||||
<code className="text-accent font-mono">{dnsInstructions.cname_target}</code>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => copyToClipboard(dnsInstructions.cname_target, 'cname')}
|
|
||||||
className="p-1 hover:bg-white/10"
|
|
||||||
>
|
|
||||||
{copied === 'cname' ? (
|
|
||||||
<Check className="w-4 h-4 text-accent" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-4 h-4 text-white/40" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleDone}
|
|
||||||
className="w-full py-3 px-4 bg-accent text-black font-semibold hover:bg-white transition-colors"
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -337,271 +94,251 @@ function ActivateModal({
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default function YieldPage() {
|
export default function YieldPage() {
|
||||||
const { subscription } = useStore()
|
const { subscription, user, logout, checkAuth } = useStore()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [dashboard, setDashboard] = useState<any>(null)
|
const [dashboard, setDashboard] = useState<any>(null)
|
||||||
const [showActivateModal, setShowActivateModal] = useState(false)
|
const [showActivateModal, setShowActivateModal] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { checkAuth() }, [checkAuth])
|
||||||
|
|
||||||
const fetchDashboard = useCallback(async () => {
|
const fetchDashboard = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.getYieldDashboard()
|
const data = await api.getYieldDashboard()
|
||||||
setDashboard(data)
|
setDashboard(data)
|
||||||
} catch (err) {
|
} catch (err) { console.error(err) }
|
||||||
console.error('Failed to load yield dashboard:', err)
|
finally { setLoading(false); setRefreshing(false) }
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
setRefreshing(false)
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchDashboard() }, [fetchDashboard])
|
||||||
fetchDashboard()
|
|
||||||
}, [fetchDashboard])
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
setRefreshing(true)
|
|
||||||
fetchDashboard()
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = dashboard?.stats
|
const stats = dashboard?.stats
|
||||||
|
|
||||||
return (
|
const mobileNavItems = [
|
||||||
<CommandCenterLayout minimal>
|
{ href: '/terminal/radar', label: 'Radar', icon: Target, active: false },
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{ href: '/terminal/market', label: 'Market', icon: Gavel, active: false },
|
||||||
{/* HEADER */}
|
{ href: '/terminal/watchlist', label: 'Watch', icon: Eye, active: false },
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp, active: false },
|
||||||
<section className="pt-6 lg:pt-8 pb-6">
|
]
|
||||||
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
|
|
||||||
<div className="space-y-3">
|
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||||
<div className="inline-flex items-center gap-2">
|
const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap
|
||||||
<TrendingUp className="w-4 h-4 text-accent" />
|
|
||||||
<span className="text-[10px] font-mono tracking-wide text-accent">Passive Income</span>
|
const drawerNavSections = [
|
||||||
</div>
|
{ title: 'Discover', items: [
|
||||||
|
{ href: '/terminal/radar', label: 'Radar', icon: Target },
|
||||||
<h1 className="font-display text-[2rem] lg:text-[2.5rem] leading-[1] tracking-[-0.02em]">
|
{ href: '/terminal/market', label: 'Market', icon: Gavel },
|
||||||
<span className="text-white">Yield</span>
|
{ href: '/terminal/intel', label: 'Intel', icon: TrendingUp },
|
||||||
</h1>
|
]},
|
||||||
|
{ title: 'Manage', items: [
|
||||||
<p className="text-white/40 text-sm max-w-md">
|
{ href: '/terminal/watchlist', label: 'Watchlist', icon: Eye },
|
||||||
Turn parked domains into passive income with intent-based monetization
|
{ href: '/terminal/sniper', label: 'Sniper', icon: Target },
|
||||||
</p>
|
]},
|
||||||
</div>
|
{ title: 'Monetize', items: [
|
||||||
|
{ href: '/terminal/yield', label: 'Yield', icon: Coins, active: true },
|
||||||
<div className="flex items-center gap-4">
|
{ href: '/terminal/listing', label: 'For Sale', icon: Tag },
|
||||||
{stats && (
|
]}
|
||||||
<div className="flex gap-6">
|
]
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-display text-accent">{stats.currency} {stats.monthly_revenue.toLocaleString()}</div>
|
|
||||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Monthly</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-display text-white">{stats.active_domains}</div>
|
|
||||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Active</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-xl font-display text-white">{stats.monthly_clicks.toLocaleString()}</div>
|
|
||||||
<div className="text-[9px] tracking-wide text-white/30 font-mono">Clicks</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/40 hover:text-white hover:bg-white/5 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowActivateModal(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-accent text-black text-sm font-semibold hover:bg-white transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Add Domain
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
return (
|
||||||
{/* STATS */}
|
<div className="min-h-screen bg-[#020202]">
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
<div className="hidden lg:block"><Sidebar /></div>
|
||||||
{stats && (
|
|
||||||
<section className="pb-6 border-b border-white/[0.08]">
|
<main className="lg:pl-[240px]">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
{/* MOBILE HEADER */}
|
||||||
<div className="p-4 bg-accent/5 border border-accent/20">
|
<header className="lg:hidden sticky top-0 z-40 bg-[#020202]/95 backdrop-blur-md border-b border-white/[0.08]" style={{ paddingTop: 'env(safe-area-inset-top)' }}>
|
||||||
<DollarSign className="w-5 h-5 text-accent mb-2" />
|
<div className="px-4 py-3">
|
||||||
<div className="text-xl font-display text-white">{stats.currency} {stats.monthly_revenue.toLocaleString()}</div>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="text-[10px] text-white/40 font-mono">Monthly Revenue</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-[10px] text-white/30 mt-1">Lifetime: {stats.currency} {stats.lifetime_revenue.toLocaleString()}</div>
|
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||||
|
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Yield</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-white/40">{stats?.active_domains || 0} active</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="p-4 bg-white/[0.02] border border-white/[0.08]">
|
<div className="bg-accent/[0.05] border border-accent/20 p-2">
|
||||||
<Zap className="w-5 h-5 text-blue-400 mb-2" />
|
<div className="text-lg font-bold text-accent tabular-nums font-mono">${stats?.monthly_revenue || 0}</div>
|
||||||
<div className="text-xl font-display text-white">{stats.active_domains}</div>
|
<div className="text-[9px] font-mono text-accent/60 uppercase tracking-wider">Monthly</div>
|
||||||
<div className="text-[10px] text-white/40 font-mono">Active Domains</div>
|
</div>
|
||||||
<div className="text-[10px] text-white/30 mt-1">{stats.pending_domains} pending</div>
|
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||||
|
<div className="text-lg font-bold text-white tabular-nums">{stats?.monthly_clicks || 0}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Clicks</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/[0.02] border border-white/[0.08] p-2">
|
||||||
|
<div className="text-lg font-bold text-white tabular-nums">${stats?.pending_payout || 0}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase tracking-wider">Pending</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="p-4 bg-white/[0.02] border border-white/[0.08]">
|
</header>
|
||||||
<MousePointer className="w-5 h-5 text-amber-400 mb-2" />
|
|
||||||
<div className="text-xl font-display text-white">{stats.monthly_clicks.toLocaleString()}</div>
|
{/* DESKTOP HEADER */}
|
||||||
<div className="text-[10px] text-white/40 font-mono">Monthly Clicks</div>
|
<section className="hidden lg:block px-10 pt-10 pb-6">
|
||||||
<div className="text-[10px] text-white/30 mt-1">{stats.monthly_conversions} conversions</div>
|
<div className="flex items-end justify-between gap-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
|
||||||
|
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Passive Income</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white">Yield</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
<div className="p-4 bg-white/[0.02] border border-white/[0.08]">
|
{stats && (
|
||||||
<Wallet className="w-5 h-5 text-purple-400 mb-2" />
|
<div className="flex gap-8">
|
||||||
<div className="text-xl font-display text-white">{stats.currency} {stats.pending_payout.toLocaleString()}</div>
|
<div className="text-right">
|
||||||
<div className="text-[10px] text-white/40 font-mono">Pending Payout</div>
|
<div className="text-2xl font-bold text-accent font-mono">${stats.monthly_revenue}</div>
|
||||||
{stats.next_payout_date && (
|
<div className="text-[9px] font-mono text-white/30 uppercase">Monthly</div>
|
||||||
<div className="text-[10px] text-white/30 mt-1">Next: {new Date(stats.next_payout_date).toLocaleDateString()}</div>
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-white font-mono">{stats.active_domains}</div>
|
||||||
|
<div className="text-[9px] font-mono text-white/30 uppercase">Active</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => { setRefreshing(true); fetchDashboard() }} disabled={refreshing}
|
||||||
|
className="w-9 h-9 flex items-center justify-center border border-white/10 text-white/40 hover:text-white">
|
||||||
|
<RefreshCw className={clsx("w-4 h-4", refreshing && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowActivateModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white">
|
||||||
|
<Plus className="w-4 h-4" />Add Domain
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
{/* ADD BUTTON MOBILE */}
|
||||||
{/* DOMAINS TABLE */}
|
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
<button onClick={() => setShowActivateModal(true)}
|
||||||
<section className="py-6">
|
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider">
|
||||||
{loading ? (
|
<Plus className="w-4 h-4" />Add Domain
|
||||||
<div className="flex items-center justify-center py-20">
|
</button>
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
</section>
|
||||||
</div>
|
|
||||||
) : dashboard?.domains?.length === 0 ? (
|
{/* CONTENT */}
|
||||||
<div className="text-center py-16">
|
<section className="px-4 lg:px-10 py-4 pb-28 lg:pb-10">
|
||||||
<div className="w-14 h-14 mx-auto bg-white/[0.02] border border-white/10 flex items-center justify-center mb-4">
|
{loading ? (
|
||||||
<TrendingUp className="w-6 h-6 text-white/20" />
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-white/40 text-sm mb-2">No yield domains yet</p>
|
) : !dashboard?.domains?.length ? (
|
||||||
<p className="text-white/25 text-xs mb-6 max-w-sm mx-auto">
|
<div className="text-center py-16 border border-dashed border-white/[0.08]">
|
||||||
Activate your first domain to start generating passive income
|
<TrendingUp className="w-8 h-8 text-white/10 mx-auto mb-3" />
|
||||||
</p>
|
<p className="text-white/40 text-sm font-mono mb-2">No yield domains yet</p>
|
||||||
<button
|
<p className="text-white/25 text-xs font-mono">Activate domains to earn passive income</p>
|
||||||
onClick={() => setShowActivateModal(true)}
|
</div>
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-accent text-black text-sm font-semibold hover:bg-white transition-colors"
|
) : (
|
||||||
>
|
<div className="space-y-px bg-white/[0.04] border border-white/[0.08]">
|
||||||
<Plus className="w-4 h-4" />
|
{/* Header */}
|
||||||
Add First Domain
|
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_80px_80px_80px] gap-4 px-3 py-2 text-[10px] font-mono text-white/40 uppercase tracking-wider border-b border-white/[0.08]">
|
||||||
</button>
|
<div>Domain</div>
|
||||||
</div>
|
<div className="text-center">Status</div>
|
||||||
) : (
|
<div>Intent</div>
|
||||||
<div className="overflow-x-auto">
|
<div className="text-right">Clicks</div>
|
||||||
<table className="w-full min-w-[800px]">
|
<div className="text-right">Conv.</div>
|
||||||
<thead>
|
<div className="text-right">Revenue</div>
|
||||||
<tr className="text-xs text-white/40 border-b border-white/[0.06]">
|
</div>
|
||||||
<th className="text-left py-3 px-4 font-medium">Domain</th>
|
|
||||||
<th className="text-left py-3 px-4 font-medium">Status</th>
|
{dashboard.domains.map((domain: YieldDomain) => (
|
||||||
<th className="text-left py-3 px-4 font-medium">Intent</th>
|
<div key={domain.id} className="bg-[#020202] hover:bg-white/[0.02] transition-all">
|
||||||
<th className="text-right py-3 px-4 font-medium">Clicks</th>
|
{/* Mobile */}
|
||||||
<th className="text-right py-3 px-4 font-medium">Conversions</th>
|
<div className="lg:hidden p-3">
|
||||||
<th className="text-right py-3 px-4 font-medium">Revenue</th>
|
<div className="flex items-center justify-between mb-2">
|
||||||
<th className="text-right py-3 px-4 font-medium"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{dashboard?.domains?.map((domain: YieldDomain) => (
|
|
||||||
<tr key={domain.id} className="group border-b border-white/[0.04] hover:bg-white/[0.02]">
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 bg-white/5 border border-white/10 flex items-center justify-center text-accent text-xs font-bold">
|
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.06] flex items-center justify-center text-accent text-xs font-bold font-mono">
|
||||||
{domain.domain.charAt(0).toUpperCase()}
|
{domain.domain.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-white">{domain.domain}</span>
|
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4">
|
|
||||||
<StatusBadge status={domain.status} />
|
<StatusBadge status={domain.status} />
|
||||||
</td>
|
</div>
|
||||||
<td className="py-3 px-4">
|
<div className="flex justify-between text-[10px] font-mono text-white/40">
|
||||||
<span className="text-sm text-white/60 capitalize">
|
<span>{domain.total_clicks} clicks</span>
|
||||||
{domain.detected_intent?.replace('_', ' ') || '-'}
|
<span className="text-accent font-bold">${domain.total_revenue}</span>
|
||||||
</span>
|
</div>
|
||||||
{domain.intent_confidence > 0 && (
|
|
||||||
<span className="text-[10px] text-white/30 ml-2 font-mono">
|
|
||||||
({Math.round(domain.intent_confidence * 100)}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right text-white/60 font-mono">
|
|
||||||
{domain.total_clicks.toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right text-white/60 font-mono">
|
|
||||||
{domain.total_conversions.toLocaleString()}
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<span className="font-medium text-accent font-mono">
|
|
||||||
{domain.currency} {domain.total_revenue.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-4 text-right">
|
|
||||||
<button className="p-1.5 hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100">
|
|
||||||
<ChevronRight className="w-4 h-4 text-white/40" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{/* RECENT TRANSACTIONS */}
|
|
||||||
{/* ═══════════════════════════════════════════════════════════════════════ */}
|
|
||||||
{dashboard?.recent_transactions?.length > 0 && (
|
|
||||||
<section className="py-6 border-t border-white/[0.06]">
|
|
||||||
<h2 className="text-xs font-mono text-white/40 tracking-wide mb-4">Recent Activity</h2>
|
|
||||||
<div className="space-y-px">
|
|
||||||
{dashboard.recent_transactions.slice(0, 5).map((tx: YieldTransaction) => (
|
|
||||||
<div key={tx.id} className="flex items-center justify-between p-4 bg-white/[0.01] border border-white/[0.04] hover:bg-white/[0.02] transition-colors">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={clsx(
|
|
||||||
"w-8 h-8 flex items-center justify-center",
|
|
||||||
tx.event_type === 'sale' ? 'bg-accent/10' :
|
|
||||||
tx.event_type === 'lead' ? 'bg-blue-400/10' :
|
|
||||||
'bg-white/5'
|
|
||||||
)}>
|
|
||||||
{tx.event_type === 'sale' ? (
|
|
||||||
<DollarSign className="w-4 h-4 text-accent" />
|
|
||||||
) : tx.event_type === 'lead' ? (
|
|
||||||
<Target className="w-4 h-4 text-blue-400" />
|
|
||||||
) : (
|
|
||||||
<MousePointer className="w-4 h-4 text-white/40" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-white capitalize">{tx.event_type}</p>
|
{/* Desktop */}
|
||||||
<p className="text-[10px] text-white/30 font-mono">{tx.partner_slug}</p>
|
<div className="hidden lg:grid grid-cols-[1fr_80px_120px_80px_80px_80px] gap-4 items-center px-3 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-white/[0.02] border border-white/[0.06] flex items-center justify-center text-accent text-xs font-bold font-mono">
|
||||||
|
{domain.domain.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold text-white font-mono">{domain.domain}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-center"><StatusBadge status={domain.status} /></div>
|
||||||
|
<span className="text-xs text-white/60 capitalize font-mono">{domain.detected_intent?.replace('_', ' ') || '—'}</span>
|
||||||
|
<div className="text-right text-xs font-mono text-white/60">{domain.total_clicks}</div>
|
||||||
|
<div className="text-right text-xs font-mono text-white/60">{domain.total_conversions}</div>
|
||||||
|
<div className="text-right text-sm font-bold font-mono text-accent">${domain.total_revenue}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
))}
|
||||||
<p className="text-sm font-medium text-accent font-mono">
|
</div>
|
||||||
+{tx.currency} {tx.net_amount.toFixed(2)}
|
)}
|
||||||
</p>
|
</section>
|
||||||
<p className="text-[10px] text-white/30 font-mono">
|
|
||||||
{new Date(tx.created_at).toLocaleDateString()}
|
{/* MOBILE BOTTOM NAV */}
|
||||||
</p>
|
<nav className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-[#020202] border-t border-white/[0.08]" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
|
<div className="flex items-stretch h-14">
|
||||||
|
{mobileNavItems.map((item) => (
|
||||||
|
<Link key={item.href} href={item.href} className={clsx("flex-1 flex flex-col items-center justify-center gap-0.5 relative", item.active ? "text-accent" : "text-white/40")}>
|
||||||
|
{item.active && <div className="absolute top-0 left-1/2 -translate-x-1/2 w-10 h-0.5 bg-accent" />}
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="text-[9px] font-mono uppercase tracking-wider">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setMenuOpen(true)} className="flex-1 flex flex-col items-center justify-center gap-0.5 text-white/40">
|
||||||
|
<Menu className="w-5 h-5" /><span className="text-[9px] font-mono uppercase tracking-wider">Menu</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* DRAWER */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-[100]">
|
||||||
|
<div className="absolute inset-0 bg-black/80" onClick={() => setMenuOpen(false)} />
|
||||||
|
<div className="absolute top-0 right-0 bottom-0 w-[80%] max-w-[300px] bg-[#0A0A0A] border-l border-white/[0.08] flex flex-col" style={{ paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)' }}>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/[0.08]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Image src="/pounce-puma.png" alt="Pounce" width={28} height={28} className="object-contain" />
|
||||||
|
<div><h2 className="text-sm font-bold text-white">POUNCE</h2><p className="text-[9px] text-white/40 font-mono uppercase">Terminal v1.0</p></div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setMenuOpen(false)} className="w-8 h-8 flex items-center justify-center border border-white/10 text-white/60"><X className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto py-4">
|
||||||
|
{drawerNavSections.map((section) => (
|
||||||
|
<div key={section.title} className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 px-4 mb-2"><div className="w-1 h-3 bg-accent" /><span className="text-[9px] font-bold text-white/30 uppercase tracking-[0.2em]">{section.title}</span></div>
|
||||||
|
{section.items.map((item: any) => (
|
||||||
|
<Link key={item.href} href={item.href} onClick={() => setMenuOpen(false)} className={clsx("flex items-center gap-3 px-4 py-2.5 border-l-2 border-transparent", item.active ? "text-accent border-accent bg-white/[0.02]" : "text-white/60")}>
|
||||||
|
<item.icon className="w-4 h-4 text-white/30" /><span className="text-sm font-medium flex-1">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="pt-3 border-t border-white/[0.08] mx-4">
|
||||||
|
<Link href="/terminal/settings" onClick={() => setMenuOpen(false)} className="flex items-center gap-3 py-2.5 text-white/50"><Settings className="w-4 h-4" /><span className="text-sm">Settings</span></Link>
|
||||||
|
{user?.is_admin && <Link href="/admin" onClick={() => setMenuOpen(false)} className="flex items-center gap-3 py-2.5 text-amber-500/70"><Shield className="w-4 h-4" /><span className="text-sm">Admin</span></Link>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="p-4 bg-white/[0.02] border-t border-white/[0.08]">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-8 h-8 bg-accent/10 border border-accent/20 flex items-center justify-center"><TierIcon className="w-4 h-4 text-accent" /></div>
|
||||||
|
<div className="flex-1 min-w-0"><p className="text-sm font-bold text-white truncate">{user?.name || user?.email?.split('@')[0] || 'User'}</p><p className="text-[9px] font-mono text-white/40 uppercase">{tierName}</p></div>
|
||||||
|
</div>
|
||||||
|
{tierName === 'Scout' && <Link href="/pricing" onClick={() => setMenuOpen(false)} className="flex items-center justify-center gap-2 w-full py-2.5 bg-accent text-black text-xs font-bold uppercase mb-2"><Sparkles className="w-3 h-3" />Upgrade</Link>}
|
||||||
|
<button onClick={() => { logout(); setMenuOpen(false) }} className="flex items-center justify-center gap-2 w-full py-2 border border-white/10 text-white/40 text-[10px] font-mono uppercase"><LogOut className="w-3 h-3" />Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
)}
|
||||||
)}
|
</main>
|
||||||
|
|
||||||
{/* Activate Modal */}
|
<ActivateModal isOpen={showActivateModal} onClose={() => setShowActivateModal(false)} onSuccess={fetchDashboard} />
|
||||||
<ActivateModal
|
</div>
|
||||||
isOpen={showActivateModal}
|
|
||||||
onClose={() => setShowActivateModal(false)}
|
|
||||||
onSuccess={fetchDashboard}
|
|
||||||
/>
|
|
||||||
</CommandCenterLayout>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,8 +100,9 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
|
|
||||||
register: async (email, password, name) => {
|
register: async (email, password, name) => {
|
||||||
await api.register(email, password, name)
|
await api.register(email, password, name)
|
||||||
// Auto-login after registration
|
// Note: No auto-login after registration
|
||||||
await get().login(email, password)
|
// User should verify email first (verification email is sent)
|
||||||
|
// They can then log in manually via the login page
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: () => {
|
logout: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user