+
Registrar Prices
Live comparison sorted by price
@@ -674,10 +800,18 @@ export default function CommandTldDetailPage() {
-
+
Registrar
Reg
- Renew
+
+ {canSeeRenewal ? 'Renew' : (
+
+
+ Renew
+
+
+ )}
+
@@ -687,11 +821,11 @@ export default function CommandTldDetailPage() {
const isBest = idx === 0 && !hasRenewalTrap
return (
-
+
{registrar.name}
- {isBest && Best Value }
- {idx === 0 && hasRenewalTrap && Renewal Trap }
+ {isBest && Best Value }
+ {idx === 0 && hasRenewalTrap && canSeeRenewal && Renewal Trap }
@@ -699,9 +833,13 @@ export default function CommandTldDetailPage() {
-
- ${registrar.renewal_price.toFixed(2)}
-
+ {canSeeRenewal ? (
+
+ ${registrar.renewal_price.toFixed(2)}
+
+ ) : (
+ —
+ )}
+
+ {/* Upgrade CTA for Scout users */}
+ {userTier === 'scout' && (
+
+
+
+ Upgrade to see renewal prices
+
+
+ )}
diff --git a/frontend/src/app/terminal/intel/page.tsx b/frontend/src/app/terminal/intel/page.tsx
index 66fafa5..d2fc38a 100755
--- a/frontend/src/app/terminal/intel/page.tsx
+++ b/frontend/src/app/terminal/intel/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useEffect, useState, useMemo, useCallback } from 'react'
+import { useEffect, useState, useMemo, useCallback, memo } from 'react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
@@ -14,90 +14,151 @@ import {
AlertTriangle,
RefreshCw,
Search,
- Filter,
ChevronDown,
ChevronUp,
Info,
ArrowRight,
+ Lock,
+ Sparkles,
BarChart3,
- PieChart
+ Activity,
+ Zap,
+ Filter,
+ Check,
+ Eye,
+ ShieldCheck,
+ Diamond,
+ Minus
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// ============================================================================
-// SHARED COMPONENTS (Matching Market/Radar Style)
+// TIER ACCESS LEVELS
// ============================================================================
-function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
- return (
-
- )
+type UserTier = 'scout' | 'trader' | 'tycoon'
+
+function getTierLevel(tier: UserTier): number {
+ switch (tier) {
+ case 'tycoon': return 3
+ case 'trader': return 2
+ case 'scout': return 1
+ default: return 1
+ }
}
-function StatCard({
+// ============================================================================
+// SHARED COMPONENTS
+// ============================================================================
+
+const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
+
+))
+Tooltip.displayName = 'Tooltip'
+
+const LockedFeature = memo(({ requiredTier, currentTier }: { requiredTier: UserTier; currentTier: UserTier }) => {
+ const tierNames = { scout: 'Scout', trader: 'Trader', tycoon: 'Tycoon' }
+ return (
+
+
+
+ Locked
+
+
+ )
+})
+LockedFeature.displayName = 'LockedFeature'
+
+const StatCard = memo(({
label,
value,
subValue,
icon: Icon,
- trend
+ highlight,
+ locked = false,
+ lockTooltip
}: {
label: string
value: string | number
subValue?: string
icon: any
- trend?: 'up' | 'down' | 'neutral' | 'active'
-}) {
- return (
-
-
-
-
{label}
+ highlight?: boolean
+ locked?: boolean
+ lockTooltip?: string
+}) => (
+
+
+
+
+
+
+
+ {label}
+
+
+ {locked ? (
+
+
+
+ —
+
+
+ ) : (
{value}
{subValue && {subValue} }
-
-
-
-
-
- )
-}
-
-function FilterToggle({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
- return (
-
- {label}
-
- )
-}
+
+ {highlight && (
+
+ ● LIVE
+
+ )}
+
+
+))
+StatCard.displayName = 'StatCard'
-function SortableHeader({
- label, field, currentSort, currentDirection, onSort, align = 'left', tooltip
+const FilterToggle = memo(({ active, onClick, label, icon: Icon }: {
+ active: boolean
+ onClick: () => void
+ label: string
+ icon?: any
+}) => (
+
+ {Icon && }
+ {label}
+
+))
+FilterToggle.displayName = 'FilterToggle'
+
+type SortField = 'tld' | 'price' | 'renewal' | 'change' | 'change3y' | 'risk' | 'popularity'
+type SortDirection = 'asc' | 'desc'
+
+const SortableHeader = memo(({
+ label, field, currentSort, currentDirection, onSort, align = 'left', tooltip, locked = false, lockTooltip
}: {
- label: string; field: SortField; currentSort: SortField; currentDirection: SortDirection; onSort: (field: SortField) => void; align?: 'left'|'center'|'right'; tooltip?: string
-}) {
+ label: string; field: SortField; currentSort: SortField; currentDirection: SortDirection; onSort: (field: SortField) => void; align?: 'left'|'center'|'right'; tooltip?: string; locked?: boolean; lockTooltip?: string
+}) => {
const isActive = currentSort === field
return (
onSort(field)}
+ onClick={() => !locked && onSort(field)}
+ disabled={locked}
className={clsx(
- "flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest transition-all group select-none py-2",
- isActive ? "text-white" : "text-zinc-500 hover:text-zinc-300"
+ "flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider transition-all group select-none py-2",
+ locked ? "text-zinc-600 cursor-not-allowed" : isActive ? "text-zinc-300" : "text-zinc-500 hover:text-zinc-400"
)}
>
{label}
-
-
-
-
+ {locked ? (
+
+
+
+ ) : (
+
+
+
+
+ )}
- {tooltip && (
+ {tooltip && !locked && (
)}
)
-}
+})
+SortableHeader.displayName = 'SortableHeader'
// ============================================================================
// TYPES
@@ -142,15 +211,13 @@ interface TLDData {
cheapest_registrar_url?: string
price_change_7d: number
price_change_1y: number
+ price_change_3y: number
risk_level: 'low' | 'medium' | 'high'
risk_reason: string
popularity_rank?: number
type?: string
}
-type SortField = 'tld' | 'price' | 'change' | 'risk' | 'popularity'
-type SortDirection = 'asc' | 'desc'
-
// ============================================================================
// MAIN PAGE
// ============================================================================
@@ -158,6 +225,15 @@ type SortDirection = 'asc' | 'desc'
export default function IntelPage() {
const { subscription } = useStore()
+ // Determine user tier
+ const userTier: UserTier = (subscription?.tier as UserTier) || 'scout'
+ const tierLevel = getTierLevel(userTier)
+
+ // Feature access checks
+ const canSeeRenewal = tierLevel >= 2 // Trader+
+ const canSee3yTrend = tierLevel >= 3 // Tycoon only
+ const canSeeFullHistory = tierLevel >= 3 // Tycoon only
+
// Data
const [tldData, setTldData] = useState
([])
const [loading, setLoading] = useState(true)
@@ -210,34 +286,36 @@ export default function IntelPage() {
}, [loadData])
const handleSort = useCallback((field: SortField) => {
+ if (field === 'renewal' && !canSeeRenewal) return
+ if (field === 'change3y' && !canSee3yTrend) return
+
if (sortField === field) setSortDirection(d => d === 'asc' ? 'desc' : 'asc')
else {
setSortField(field)
- setSortDirection(field === 'price' || field === 'risk' ? 'asc' : 'desc')
+ setSortDirection(field === 'price' || field === 'renewal' || field === 'risk' ? 'asc' : 'desc')
}
- }, [sortField])
+ }, [sortField, canSeeRenewal, canSee3yTrend])
// Transform & Filter
const filteredData = useMemo(() => {
let data = tldData
- // Category Filter
if (filterType === 'tech') data = data.filter(t => ['ai', 'io', 'app', 'dev', 'tech', 'cloud'].includes(t.tld))
if (filterType === 'geo') data = data.filter(t => ['us', 'uk', 'de', 'ch', 'fr', 'eu'].includes(t.tld))
if (filterType === 'budget') data = data.filter(t => t.min_price < 10)
- // Search
if (searchQuery) {
data = data.filter(t => t.tld.toLowerCase().includes(searchQuery.toLowerCase()))
}
- // Sort
const mult = sortDirection === 'asc' ? 1 : -1
data.sort((a, b) => {
switch (sortField) {
case 'tld': return mult * a.tld.localeCompare(b.tld)
case 'price': return mult * (a.min_price - b.min_price)
+ case 'renewal': return mult * (a.min_renewal_price - b.min_renewal_price)
case 'change': return mult * ((a.price_change_1y || 0) - (b.price_change_1y || 0))
+ case 'change3y': return mult * ((a.price_change_3y || 0) - (b.price_change_3y || 0))
case 'risk':
const riskMap = { low: 1, medium: 2, high: 3 }
return mult * (riskMap[a.risk_level] - riskMap[b.risk_level])
@@ -249,227 +327,301 @@ export default function IntelPage() {
return data
}, [tldData, filterType, searchQuery, sortField, sortDirection])
- // Stats
const stats = useMemo(() => {
const lowest = tldData.length > 0 ? Math.min(...tldData.map(t => t.min_price)) : 0
- const hottest = tldData.reduce((prev, current) => (prev.price_change_7d > current.price_change_7d) ? prev : current, tldData[0] || {})
+ const hottest = tldData.reduce((prev, current) => (prev.price_change_1y > current.price_change_1y) ? prev : current, tldData[0] || {})
const traps = tldData.filter(t => t.risk_level === 'high').length
- return { lowest, hottest, traps }
+ const avgRenewal = tldData.length > 0 ? tldData.reduce((sum, t) => sum + t.min_renewal_price, 0) / tldData.length : 0
+ return { lowest, hottest, traps, avgRenewal }
}, [tldData])
const formatPrice = (p: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(p)
return (
-
-
- {/* Glow Effect */}
-
-
+
+
+
+ {/* Ambient Background Glow */}
+
-
+
- {/* METRICS */}
-
-
-
-
0 ? '+' : ''}${stats.hottest?.price_change_7d}% (7d)`} icon={TrendingUp} trend="active" />
-
+ {/* Header Section */}
+
+
+
+
+ Inflation Monitor & Pricing Analytics across 800+ TLDs.
+
+
+
+ {/* Quick Stats Pills */}
+
+
+
+ {userTier === 'tycoon' ? 'Tycoon Access' : userTier === 'trader' ? 'Trader Access' : 'Scout Access'}
+
+
+
- {/* CONTROLS */}
-
-
- {/* Search */}
-
-
- setSearchQuery(e.target.value)}
- placeholder="Search TLDs (e.g. .io)..."
- className="w-full pl-10 pr-4 py-2.5 bg-zinc-900 border border-white/10 rounded-xl
- text-sm text-white placeholder:text-zinc-600
- focus:outline-none focus:border-white/20 focus:ring-1 focus:ring-white/20 transition-all"
- />
-
-
- {/* Filters */}
-
- setFilterType('all')} label="All TLDs" />
- setFilterType('tech')} label="Tech" />
- setFilterType('geo')} label="Geo / National" />
- setFilterType('budget')} label="Budget <$10" />
-
+ {/* Metric Grid */}
+
+
+
+
+
+
-
-
-
-
- Refresh Data
-
+ {/* Control Bar */}
+
+ {/* Filter Pills */}
+
+ setFilterType('all')} label="All TLDs" />
+ setFilterType('tech')} label="Tech" icon={Zap} />
+ setFilterType('geo')} label="Geo / National" icon={Globe} />
+ setFilterType('budget')} label="Budget <$10" icon={DollarSign} />
+
+
+ {/* Refresh Button (Mobile) */}
+
+
+
+
+ {/* Search Filter */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search TLDs..."
+ className="w-full bg-black/50 border border-white/10 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-white/20 transition-all"
+ />
{/* DATA GRID */}
-
- {loading ? (
-
-
-
Analyzing registry data...
-
- ) : filteredData.length === 0 ? (
-
-
-
-
-
No TLDs found
-
Try adjusting your filters
-
- ) : (
- <>
- {/* DESKTOP TABLE */}
-
-
-
-
-
-
-
-
Action
+
+
+ {/* Unified Table Header - Use a wrapper with min-width to force scrolling instead of breaking */}
+
+
{/* Force minimum width */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {canSee3yTrend ? (
+
+ ) : (
+ Trend (3y)
+ )}
+
+
+
+
+
Action
-
- {filteredData.map((tld) => {
- const isTrap = tld.min_renewal_price > tld.min_price * 1.5
- const trend = tld.price_change_1y || 0
-
- return (
-
- {/* TLD */}
-
-
- .{tld.tld}
-
-
-
- {/* Price */}
-
- {formatPrice(tld.min_price)}
-
- {/* Renewal */}
-
-
- {formatPrice(tld.min_renewal_price)}
-
- {isTrap && (
-
-
-
- )}
-
-
- {/* Trend */}
-
-
5 ? "bg-orange-500/10 text-orange-400" :
- trend < -5 ? "bg-emerald-500/10 text-emerald-400" :
- "text-zinc-500"
- )}>
- {trend > 0 ?
: trend < 0 ?
: null}
- {Math.abs(trend)}%
+ {/* Rows */}
+ {loading ? (
+
+
+
Analyzing registry data...
+
+ ) : filteredData.length === 0 ? (
+
+
+
+
+
No TLDs found
+
+ Try adjusting your filters or search query.
+
+
+ ) : (
+
+ {filteredData.map((tld) => {
+ const isTrap = tld.min_renewal_price > tld.min_price * 1.5
+ const trend = tld.price_change_1y || 0
+ const trend3y = tld.price_change_3y || 0
+
+ return (
+
+ {/* TLD */}
+
+
+ .{tld.tld}
+
-
-
- {/* Risk */}
-
-
- {/* Action / Provider */}
-
-
- )
- })}
-
-
-
- {/* MOBILE CARDS */}
-
- {filteredData.map((tld) => {
- const isTrap = tld.min_renewal_price > tld.min_price * 1.5
- return (
-
-
-
-
.{tld.tld}
-
- {tld.risk_level} Risk
+
+ {/* Price */}
+
+ {formatPrice(tld.min_price)}
-
-
-
-
-
Register
-
{formatPrice(tld.min_price)}
+
+ {/* Renewal (Trader+) */}
+
+ {canSeeRenewal ? (
+ <>
+
+ {formatPrice(tld.min_renewal_price)}
+
+ {isTrap && (
+
+
+
+ )}
+ >
+ ) : (
+
+ )}
-
-
Renew
-
- {formatPrice(tld.min_renewal_price)}
+
+ {/* Trend 1y */}
+
+
5 ? "text-orange-400 bg-orange-400/5 border border-orange-400/20" :
+ trend < -5 ? "text-emerald-400 bg-emerald-400/5 border border-emerald-400/20" :
+ "text-zinc-400 bg-zinc-800/50 border border-zinc-700"
+ )}>
+ {trend > 0 ? : trend < 0 ? : }
+ {Math.abs(trend)}%
-
-
-
-
Provider:
-
- {tld.cheapest_registrar || '-'}
-
+ {/* Trend 3y */}
+
+ {canSee3yTrend ? (
+
10 ? "text-orange-400 bg-orange-400/5 border border-orange-400/20" :
+ trend3y < -10 ? "text-emerald-400 bg-emerald-400/5 border border-emerald-400/20" :
+ "text-zinc-400 bg-zinc-800/50 border border-zinc-700"
+ )}>
+ {trend3y > 0 ? : trend3y < 0 ? : }
+ {Math.abs(trend3y)}%
+
+ ) : (
+
—
+ )}
-
- Details
+
+ {/* Risk */}
+
+
+ {/* Action */}
+
-
-
- )
- })}
-
- >
- )}
+ )
+ })}
+
+ )}
+
+
+
+ {/* Upgrade CTA for Scout users */}
+ {userTier === 'scout' && (
+
+
+
+
+
Unlock Full TLD Intelligence
+
+ See renewal prices, identify renewal traps, and access detailed price history charts with Trader or Tycoon.
+
+
+
+ Upgrade Now
+
+
+ )}
diff --git a/frontend/src/app/terminal/listing/page.tsx b/frontend/src/app/terminal/listing/page.tsx
index d37e016..e7ef013 100755
--- a/frontend/src/app/terminal/listing/page.tsx
+++ b/frontend/src/app/terminal/listing/page.tsx
@@ -126,6 +126,19 @@ interface VerificationInfo {
status: string
}
+interface Inquiry {
+ id: number
+ name: string
+ email: string
+ phone: string | null
+ company: string | null
+ message: string
+ offer_amount: number | null
+ status: string
+ created_at: string
+ read_at: string | null
+}
+
// ============================================================================
// MAIN PAGE
// ============================================================================
@@ -141,8 +154,11 @@ export default function MyListingsPage() {
// Modals
const [showCreateModal, setShowCreateModal] = useState(false)
const [showVerifyModal, setShowVerifyModal] = useState(false)
+ const [showInquiriesModal, setShowInquiriesModal] = useState(false)
const [selectedListing, setSelectedListing] = useState
(null)
const [verificationInfo, setVerificationInfo] = useState(null)
+ const [inquiries, setInquiries] = useState([])
+ const [loadingInquiries, setLoadingInquiries] = useState(false)
const [verifying, setVerifying] = useState(false)
const [creating, setCreating] = useState(false)
const [error, setError] = useState(null)
@@ -226,6 +242,22 @@ export default function MyListingsPage() {
}
}
+ const handleViewInquiries = async (listing: Listing) => {
+ setSelectedListing(listing)
+ setLoadingInquiries(true)
+ setShowInquiriesModal(true)
+
+ try {
+ const data = await api.request(`/listings/${listing.id}/inquiries`)
+ setInquiries(data)
+ } catch (err: any) {
+ setError(err.message)
+ setShowInquiriesModal(false)
+ } finally {
+ setLoadingInquiries(false)
+ }
+ }
+
const handleCheckVerification = async () => {
if (!selectedListing) return
setVerifying(true)
@@ -289,11 +321,12 @@ export default function MyListingsPage() {
}).format(price)
}
- // Tier limits
+ // Tier limits (from pounce_pricing.md: Trader=5, Tycoon=50, Scout=0)
const tier = subscription?.tier || 'scout'
const limits = { scout: 0, trader: 5, tycoon: 50 }
const maxListings = limits[tier as keyof typeof limits] || 0
const canList = tier !== 'scout'
+ const isTycoon = tier === 'tycoon'
const activeCount = listings.filter(l => l.status === 'active').length
const totalViews = listings.reduce((sum, l) => sum + l.view_count, 0)
@@ -316,10 +349,10 @@ export default function MyListingsPage() {
- Manage your domain inventory, track performance, and process offers.
+ List your domains on the Pounce Marketplace. 0% commission, instant visibility.
@@ -416,10 +449,11 @@ export default function MyListingsPage() {
{/* Table Header */}
-
Domain
+
Domain
Status
Price
Views
+
Inquiries
Actions
@@ -476,17 +510,28 @@ export default function MyListingsPage() {
{/* Desktop View */}
-
+
{listing.domain.charAt(0).toUpperCase()}
+ {/* Featured Badge for Tycoon */}
+ {isTycoon && listing.status === 'active' && (
+
+
+
+ )}
-
{listing.domain}
-
{listing.title || 'No description provided'}
+
+ {listing.domain}
+ {listing.is_verified && (
+
+ )}
+
+
{listing.title || 'No headline'}
@@ -496,9 +541,10 @@ export default function MyListingsPage() {
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider border",
listing.status === 'active' ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" :
listing.status === 'draft' ? "bg-zinc-800/50 text-zinc-400 border-zinc-700" :
- "bg-blue-500/10 text-blue-400 border-blue-500/20"
+ listing.status === 'sold' ? "bg-blue-500/10 text-blue-400 border-blue-500/20" :
+ "bg-zinc-800/50 text-zinc-400 border-zinc-700"
)}>
-
+
{listing.status}
@@ -512,6 +558,22 @@ export default function MyListingsPage() {
{listing.view_count}
+
+ handleViewInquiries(listing)}
+ disabled={listing.inquiry_count === 0}
+ className={clsx(
+ "text-sm font-medium transition-colors",
+ listing.inquiry_count > 0
+ ? "text-amber-400 hover:text-amber-300 cursor-pointer"
+ : "text-zinc-600 cursor-default"
+ )}
+ >
+ {listing.inquiry_count}
+ {listing.inquiry_count > 0 && 📩 }
+
+
+
{!listing.is_verified ? (
@@ -719,6 +781,88 @@ export default function MyListingsPage() {
)}
+
+ {/* Inquiries Modal */}
+ {showInquiriesModal && selectedListing && (
+
+
+
+
+
Inquiries
+
+ {inquiries.length} inquiry{inquiries.length !== 1 ? 'ies' : ''} for {selectedListing.domain}
+
+
+
setShowInquiriesModal(false)} className="p-2 text-zinc-400 hover:text-white transition-colors">
+
+
+
+
+
+ {loadingInquiries ? (
+
+
+
+ ) : inquiries.length === 0 ? (
+
+ ) : (
+ inquiries.map((inquiry) => (
+
+
+
+
{inquiry.name}
+
{inquiry.email}
+ {inquiry.company &&
{inquiry.company}
}
+
+
+ {inquiry.offer_amount && (
+
+ ${inquiry.offer_amount.toLocaleString()}
+
+ )}
+
+ {new Date(inquiry.created_at).toLocaleDateString()}
+
+
+
+
+ {inquiry.message}
+
+
+
+ ))
+ )}
+
+
+
+ setShowInquiriesModal(false)}
+ className="w-full px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
+ >
+ Close
+
+
+
+
+ )}
)
diff --git a/frontend/src/app/terminal/market/page.tsx b/frontend/src/app/terminal/market/page.tsx
index ef02922..f92b91d 100644
--- a/frontend/src/app/terminal/market/page.tsx
+++ b/frontend/src/app/terminal/market/page.tsx
@@ -30,7 +30,10 @@ import {
Info,
ShieldCheck,
Sparkles,
- Store
+ Store,
+ DollarSign,
+ Gavel,
+ Ban
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
@@ -89,6 +92,12 @@ function formatPrice(price: number, currency = 'USD'): string {
}).format(price)
}
+function isSpam(domain: string): boolean {
+ // Check for hyphens or numbers in the name part (excluding TLD)
+ const name = domain.split('.')[0]
+ return /[-\d]/.test(name)
+}
+
// ============================================================================
// COMPONENTS
// ============================================================================
@@ -105,7 +114,7 @@ const Tooltip = memo(({ children, content }: { children: React.ReactNode; conten
))
Tooltip.displayName = 'Tooltip'
-// Stat Card
+// Stat Card (Matched to Watchlist Page)
const StatCard = memo(({
label,
value,
@@ -116,26 +125,30 @@ const StatCard = memo(({
label: string
value: string | number
subValue?: string
- icon: React.ElementType
+ icon: any
highlight?: boolean
}) => (
-
+
+
+
-
{label}
+
+
+ {label}
+
{value}
{subValue && {subValue} }
-
-
-
+ {highlight && (
+
+ ● LIVE
+
+ )}
))
@@ -195,18 +208,18 @@ const FilterToggle = memo(({ active, onClick, label, icon: Icon }: {
active: boolean
onClick: () => void
label: string
- icon?: React.ElementType
+ icon?: any
}) => (
- {Icon && }
+ {Icon && }
{label}
))
@@ -234,14 +247,14 @@ const SortableHeader = memo(({
onSort(field)}
className={clsx(
- "flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest transition-all group select-none py-2",
- isActive ? "text-white" : "text-zinc-500 hover:text-zinc-300"
+ "flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider transition-all group select-none py-2",
+ isActive ? "text-zinc-300" : "text-zinc-500 hover:text-zinc-400"
)}
>
{label}
-
-
+
+
{tooltip && (
@@ -254,29 +267,6 @@ const SortableHeader = memo(({
})
SortableHeader.displayName = 'SortableHeader'
-// Pounce Direct Badge
-const PounceBadge = memo(({ verified }: { verified: boolean }) => (
-
- {verified ? (
- <>
-
- Verified
- >
- ) : (
- <>
-
- Pounce
- >
- )}
-
-))
-PounceBadge.displayName = 'PounceBadge'
-
// ============================================================================
// MAIN PAGE
// ============================================================================
@@ -295,6 +285,8 @@ export default function MarketPage() {
const [searchQuery, setSearchQuery] = useState('')
const [priceRange, setPriceRange] = useState
('all')
const [verifiedOnly, setVerifiedOnly] = useState(false)
+ const [hideSpam, setHideSpam] = useState(true)
+ const [tldFilter, setTldFilter] = useState('all')
// Sort
const [sortField, setSortField] = useState('score')
@@ -311,6 +303,7 @@ export default function MarketPage() {
const result = await api.getMarketFeed({
source: sourceFilter,
keyword: searchQuery || undefined,
+ tld: tldFilter === 'all' ? undefined : tldFilter,
minPrice: priceRange === 'low' ? undefined : priceRange === 'mid' ? 100 : priceRange === 'high' ? 1000 : undefined,
maxPrice: priceRange === 'low' ? 100 : priceRange === 'mid' ? 1000 : undefined,
verifiedOnly,
@@ -333,7 +326,7 @@ export default function MarketPage() {
} finally {
setLoading(false)
}
- }, [sourceFilter, searchQuery, priceRange, verifiedOnly, sortField, sortDirection])
+ }, [sourceFilter, searchQuery, priceRange, verifiedOnly, sortField, sortDirection, tldFilter])
useEffect(() => { loadData() }, [loadData])
@@ -365,21 +358,26 @@ export default function MarketPage() {
}
}, [trackedDomains, trackingInProgress])
- // Client-side filtering for immediate UI feedback
+ // Client-side filtering for immediate UI feedback & SPAM FILTER
const filteredItems = useMemo(() => {
let filtered = items
-
- // Additional client-side search (API already filters, but this is for instant feedback)
+
+ // Additional client-side search
if (searchQuery && !loading) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(item => item.domain.toLowerCase().includes(query))
}
+ // Hide Spam (Client-side)
+ if (hideSpam) {
+ filtered = filtered.filter(item => !isSpam(item.domain))
+ }
+
// Sort
const mult = sortDirection === 'asc' ? 1 : -1
filtered = [...filtered].sort((a, b) => {
- // Pounce Direct always appears first within same score tier
- if (a.is_pounce !== b.is_pounce && sortField === 'score') {
+ // Pounce Direct always appears first within same score tier if score sort
+ if (sortField === 'score' && a.is_pounce !== b.is_pounce) {
return a.is_pounce ? -1 : 1
}
@@ -394,341 +392,307 @@ export default function MarketPage() {
})
return filtered
- }, [items, searchQuery, sortField, sortDirection, loading])
-
- // Separate Pounce Direct from external
- const pounceItems = useMemo(() => filteredItems.filter(i => i.is_pounce), [filteredItems])
- const externalItems = useMemo(() => filteredItems.filter(i => !i.is_pounce), [filteredItems])
+ }, [items, searchQuery, sortField, sortDirection, loading, hideSpam])
return (
-
-
- {/* Ambient glow */}
-
-
+
+
+
+ {/* Ambient Background Glow (Matched to Watchlist) */}
+
-
+
- {/* METRICS */}
-
-
-
0} />
-
-
+ {/* Header Section (Matched to Watchlist) */}
+
+
+
+
+ Real-time auctions from Pounce Direct, GoDaddy, Sedo, and DropCatch.
+
+
+
+ {/* Quick Stats Pills */}
+
+
+
+ {stats.pounceCount} Exclusive
+
+
+
+ {stats.auctionCount} External
+
+
- {/* CONTROLS */}
-
-
- {/* Search */}
-
-
- setSearchQuery(e.target.value)}
- placeholder="Search domains..."
- className="w-full pl-10 pr-4 py-2.5 bg-zinc-900 border border-white/10 rounded-xl
- text-sm text-white placeholder:text-zinc-600
- focus:outline-none focus:border-white/20 focus:ring-1 focus:ring-white/20 transition-all"
- />
-
+ {/* Metric Grid (Matched to Watchlist) */}
+
+
+
+
+
+
+
+ {/* Control Bar (Matched to Watchlist) */}
+
+ {/* Filter Pills */}
+
+
setHideSpam(!hideSpam)}
+ label="Hide Spam"
+ icon={Ban}
+ />
+
+ setSourceFilter(f => f === 'pounce' ? 'all' : 'pounce')}
+ label="Pounce Only"
+ icon={Diamond}
+ />
- {/* Filters */}
-
-
setSourceFilter(f => f === 'pounce' ? 'all' : 'pounce')}
- label="Pounce Only"
- icon={Diamond}
- />
- setVerifiedOnly(!verifiedOnly)}
- label="Verified"
- icon={ShieldCheck}
- />
-
- setPriceRange(p => p === 'low' ? 'all' : 'low')}
- label="< $100"
- />
- setPriceRange(p => p === 'high' ? 'all' : 'high')}
- label="$1k+"
- />
+ {/* TLD Dropdown (Simulated with select) */}
+
+ setTldFilter(e.target.value)}
+ className="appearance-none bg-black/50 border border-white/10 text-white text-xs font-medium rounded-md pl-3 pr-8 py-1.5 focus:outline-none hover:bg-white/5 cursor-pointer"
+ >
+ All TLDs
+ .com
+ .ai
+ .io
+ .net
+ .org
+ .ch
+ .de
+
+
-
+
-
-
- Refresh
-
+ setPriceRange(p => p === 'low' ? 'all' : 'low')}
+ label="< $100"
+ />
+ setPriceRange(p => p === 'mid' ? 'all' : 'mid')}
+ label="< $1k"
+ />
+ setPriceRange(p => p === 'high' ? 'all' : 'high')}
+ label="High Roller"
+ />
+
+
+ {/* Refresh Button (Mobile) */}
+
+
+
+
+ {/* Search Filter */}
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Search domains..."
+ className="w-full bg-black/50 border border-white/10 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-white/20 transition-all"
+ />
{/* DATA GRID */}
-
+
+ {/* Unified Table Header */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Action
+
+
{loading ? (
-
Scanning markets...
+
Scanning live markets...
) : filteredItems.length === 0 ? (
-
-
-
+
+
+
-
No matches found
-
Try adjusting your filters
+
No matches found
+
+ Try adjusting your filters or search query.
+
) : (
-
-
- {/* POUNCE DIRECT SECTION (if any) */}
- {pounceItems.length > 0 && (
-
-
-
-
- Pounce Direct
-
-
Verified • Instant Buy • 0% Commission
-
-
-
-
- {pounceItems.map((item) => (
-
- {/* Domain */}
-
-
-
-
-
{item.domain}
-
+
+ {filteredItems.map((item) => {
+ const timeLeftSec = parseTimeToSeconds(item.time_remaining)
+ const isUrgent = timeLeftSec < 3600
+ const isPounce = item.is_pounce
+
+ return (
+
+ {/* Domain */}
+
+
+ ) : (
+
+ {item.source.substring(0, 2).toUpperCase()}
+
+ )}
- {/* Score */}
-
-
+
+
+ {item.domain}
+
+
+ {item.source}
+ {isPounce && item.verified && (
+ <>
+ •
+
+
+ Verified
+
+ >
+ )}
+ {!isPounce && item.num_bids ? `• ${item.num_bids} bids` : ''}
+
-
- {/* Price */}
-
-
{formatPrice(item.price, item.currency)}
-
Instant Buy
-
-
- {/* Action */}
-
-
- handleTrack(item.domain)}
- disabled={trackedDomains.has(item.domain)}
- className={clsx(
- "w-8 h-8 flex items-center justify-center rounded-full border transition-all",
- trackedDomains.has(item.domain)
- ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20 cursor-default"
- : "border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-white hover:border-zinc-500 hover:scale-105"
- )}
- >
- {trackedDomains.has(item.domain) ? : }
-
-
-
-
- Buy Now
-
-
-
-
- ))}
-
-
- )}
-
- {/* EXTERNAL AUCTIONS */}
- {externalItems.length > 0 && (
-
-
-
-
- External Auctions
-
-
{externalItems.length} from global platforms
-
-
-
- {/* Desktop Table */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Action
-
- {externalItems.map((item) => {
- const timeLeftSec = parseTimeToSeconds(item.time_remaining)
- const isUrgent = timeLeftSec < 3600
- return (
-
- {/* Domain */}
-
-
{item.domain}
-
{item.source}
-
-
- {/* Score */}
-
-
-
-
- {/* Price */}
-
-
{formatPrice(item.price, item.currency)}
- {item.num_bids !== undefined && item.num_bids > 0 && (
-
{item.num_bids} bids
- )}
-
-
- {/* Time */}
-
-
-
- {item.time_remaining || 'N/A'}
-
-
-
- {/* Actions */}
-
-
- handleTrack(item.domain)}
- disabled={trackedDomains.has(item.domain)}
- className={clsx(
- "w-8 h-8 flex items-center justify-center rounded-full border transition-all",
- trackedDomains.has(item.domain)
- ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20 cursor-default"
- : "border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-white hover:border-zinc-500 hover:scale-105"
- )}
- >
- {trackedDomains.has(item.domain) ? : }
-
-
-
-
- Place Bid
-
-
-
-
- )
- })}
+ {/* Score */}
+
+
+
+
+ {/* Price */}
+
+
+ {formatPrice(item.price, item.currency)}
+
+
+ {item.price_type === 'bid' ? 'Current Bid' : 'Buy Now'}
+
+
+
+ {/* Status/Time */}
+
+ {isPounce ? (
+
+
+ Instant
+
+ ) : (
+
+
+ {item.time_remaining || 'N/A'}
+
+ )}
+
+
+ {/* Actions */}
+
+
+ handleTrack(item.domain)}
+ disabled={trackedDomains.has(item.domain)}
+ className={clsx(
+ "w-8 h-8 flex items-center justify-center rounded-lg border transition-all",
+ trackedDomains.has(item.domain)
+ ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20 cursor-default"
+ : "border-zinc-700 bg-zinc-900 text-zinc-400 hover:text-white hover:border-zinc-500"
+ )}
+ >
+ {trackedDomains.has(item.domain) ? : }
+
+
+
+
+ {isPounce ? 'Buy' : 'Bid'}
+ {isPounce ? : }
+
-
- {/* Mobile Cards */}
-
- {externalItems.map((item) => {
- const timeLeftSec = parseTimeToSeconds(item.time_remaining)
- const isUrgent = timeLeftSec < 3600
- return (
-
-
- {item.domain}
-
-
-
-
-
-
Current Bid
-
{formatPrice(item.price, item.currency)}
-
-
-
Ends In
-
-
- {item.time_remaining || 'N/A'}
-
-
-
-
-
-
handleTrack(item.domain)}
- disabled={trackedDomains.has(item.domain)}
- className={clsx(
- "flex items-center justify-center gap-2 py-3 rounded-xl text-sm font-medium border transition-all",
- trackedDomains.has(item.domain)
- ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
- : "bg-zinc-800/30 text-zinc-400 border-zinc-700/50 active:scale-95"
- )}
- >
- {trackedDomains.has(item.domain) ? (
- <> Tracked>
- ) : (
- <> Watch>
- )}
-
-
- Place Bid
-
-
-
-
- )
- })}
-
-
- )}
+ )
+ })}
)}
@@ -737,3 +701,4 @@ export default function MarketPage() {
)
}
+
diff --git a/frontend/src/app/terminal/portfolio/page.tsx b/frontend/src/app/terminal/portfolio/page.tsx
new file mode 100644
index 0000000..39b6dbb
--- /dev/null
+++ b/frontend/src/app/terminal/portfolio/page.tsx
@@ -0,0 +1,987 @@
+'use client'
+
+import { useEffect, useState, useCallback } from 'react'
+import { useStore } from '@/lib/store'
+import { api } from '@/lib/api'
+import { TerminalLayout } from '@/components/TerminalLayout'
+import {
+ Plus,
+ TrendingUp,
+ TrendingDown,
+ Wallet,
+ DollarSign,
+ Calendar,
+ RefreshCw,
+ Trash2,
+ Edit3,
+ Loader2,
+ CheckCircle,
+ AlertCircle,
+ X,
+ Briefcase,
+ PiggyBank,
+ Target,
+ ArrowRight,
+ MoreHorizontal,
+ Tag,
+ Clock,
+ Sparkles,
+ Shield
+} from 'lucide-react'
+import Link from 'next/link'
+import clsx from 'clsx'
+
+// ============================================================================
+// SHARED COMPONENTS
+// ============================================================================
+
+function StatCard({
+ label,
+ value,
+ subValue,
+ icon: Icon,
+ trend,
+ color = 'emerald'
+}: {
+ label: string
+ value: string | number
+ subValue?: string
+ icon: any
+ trend?: 'up' | 'down' | 'neutral'
+ color?: 'emerald' | 'blue' | 'amber' | 'rose'
+}) {
+ const colors = {
+ emerald: 'text-emerald-400',
+ blue: 'text-blue-400',
+ amber: 'text-amber-400',
+ rose: 'text-rose-400',
+ }
+
+ return (
+
+
+
+
+
+
+
+ {label}
+
+
+ {value}
+ {subValue && {subValue} }
+
+ {trend && (
+
+ {trend === 'up' ? : trend === 'down' ? : null}
+ {trend === 'up' ? 'PROFIT' : trend === 'down' ? 'LOSS' : 'NEUTRAL'}
+
+ )}
+
+
+ )
+}
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+interface PortfolioDomain {
+ id: number
+ domain: string
+ purchase_date: string | null
+ purchase_price: number | null
+ purchase_registrar: string | null
+ registrar: string | null
+ renewal_date: string | null
+ renewal_cost: number | null
+ auto_renew: boolean
+ estimated_value: number | null
+ value_updated_at: string | null
+ is_sold: boolean
+ sale_date: string | null
+ sale_price: number | null
+ status: string
+ notes: string | null
+ tags: string | null
+ roi: number | null
+ created_at: string
+ updated_at: string
+}
+
+interface PortfolioSummary {
+ total_domains: number
+ active_domains: number
+ sold_domains: number
+ total_invested: number
+ total_value: number
+ total_sold_value: number
+ unrealized_profit: number
+ realized_profit: number
+ overall_roi: number
+}
+
+// ============================================================================
+// MAIN PAGE
+// ============================================================================
+
+export default function PortfolioPage() {
+ const { subscription } = useStore()
+
+ const [domains, setDomains] = useState
([])
+ const [summary, setSummary] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ // Modals
+ const [showAddModal, setShowAddModal] = useState(false)
+ const [showEditModal, setShowEditModal] = useState(false)
+ const [showSellModal, setShowSellModal] = useState(false)
+ const [showListModal, setShowListModal] = useState(false)
+ const [selectedDomain, setSelectedDomain] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+
+ // List for sale form
+ const [listData, setListData] = useState({
+ asking_price: '',
+ price_type: 'negotiable',
+ })
+
+ // Form state
+ const [formData, setFormData] = useState({
+ domain: '',
+ purchase_date: '',
+ purchase_price: '',
+ registrar: '',
+ renewal_date: '',
+ renewal_cost: '',
+ notes: '',
+ tags: '',
+ })
+
+ const [sellData, setSellData] = useState({
+ sale_date: new Date().toISOString().split('T')[0],
+ sale_price: '',
+ })
+
+ const loadData = useCallback(async () => {
+ setLoading(true)
+ try {
+ const [domainsData, summaryData] = await Promise.all([
+ api.request('/portfolio'),
+ api.request('/portfolio/summary'),
+ ])
+ setDomains(domainsData)
+ setSummary(summaryData)
+ } catch (err: any) {
+ console.error('Failed to load portfolio:', err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ loadData()
+ }, [loadData])
+
+ const handleAdd = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setSaving(true)
+ setError(null)
+
+ try {
+ await api.request('/portfolio', {
+ method: 'POST',
+ body: JSON.stringify({
+ domain: formData.domain,
+ purchase_date: formData.purchase_date || null,
+ purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price) : null,
+ registrar: formData.registrar || null,
+ renewal_date: formData.renewal_date || null,
+ renewal_cost: formData.renewal_cost ? parseFloat(formData.renewal_cost) : null,
+ notes: formData.notes || null,
+ tags: formData.tags || null,
+ }),
+ })
+ setSuccess('Domain added to portfolio!')
+ setShowAddModal(false)
+ setFormData({ domain: '', purchase_date: '', purchase_price: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '', tags: '' })
+ loadData()
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleEdit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!selectedDomain) return
+ setSaving(true)
+ setError(null)
+
+ try {
+ await api.request(`/portfolio/${selectedDomain.id}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ purchase_date: formData.purchase_date || null,
+ purchase_price: formData.purchase_price ? parseFloat(formData.purchase_price) : null,
+ registrar: formData.registrar || null,
+ renewal_date: formData.renewal_date || null,
+ renewal_cost: formData.renewal_cost ? parseFloat(formData.renewal_cost) : null,
+ notes: formData.notes || null,
+ tags: formData.tags || null,
+ }),
+ })
+ setSuccess('Domain updated!')
+ setShowEditModal(false)
+ loadData()
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleSell = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!selectedDomain) return
+ setSaving(true)
+ setError(null)
+
+ try {
+ await api.request(`/portfolio/${selectedDomain.id}/sell`, {
+ method: 'POST',
+ body: JSON.stringify({
+ sale_date: sellData.sale_date,
+ sale_price: parseFloat(sellData.sale_price),
+ }),
+ })
+ setSuccess(`🎉 Congratulations! ${selectedDomain.domain} marked as sold!`)
+ setShowSellModal(false)
+ loadData()
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async (domain: PortfolioDomain) => {
+ if (!confirm(`Remove ${domain.domain} from portfolio?`)) return
+
+ try {
+ await api.request(`/portfolio/${domain.id}`, { method: 'DELETE' })
+ setSuccess('Domain removed from portfolio')
+ loadData()
+ } catch (err: any) {
+ setError(err.message)
+ }
+ }
+
+ const handleRefreshValue = async (domain: PortfolioDomain) => {
+ try {
+ await api.request(`/portfolio/${domain.id}/refresh-value`, { method: 'POST' })
+ setSuccess(`Value refreshed for ${domain.domain}`)
+ loadData()
+ } catch (err: any) {
+ setError(err.message)
+ }
+ }
+
+ const openEditModal = (domain: PortfolioDomain) => {
+ setSelectedDomain(domain)
+ setFormData({
+ domain: domain.domain,
+ purchase_date: domain.purchase_date?.split('T')[0] || '',
+ purchase_price: domain.purchase_price?.toString() || '',
+ registrar: domain.registrar || '',
+ renewal_date: domain.renewal_date?.split('T')[0] || '',
+ renewal_cost: domain.renewal_cost?.toString() || '',
+ notes: domain.notes || '',
+ tags: domain.tags || '',
+ })
+ setShowEditModal(true)
+ }
+
+ const openSellModal = (domain: PortfolioDomain) => {
+ setSelectedDomain(domain)
+ setSellData({
+ sale_date: new Date().toISOString().split('T')[0],
+ sale_price: domain.estimated_value?.toString() || '',
+ })
+ setShowSellModal(true)
+ }
+
+ const openListModal = (domain: PortfolioDomain) => {
+ setSelectedDomain(domain)
+ setListData({
+ asking_price: domain.estimated_value?.toString() || '',
+ price_type: 'negotiable',
+ })
+ setShowListModal(true)
+ }
+
+ const handleListForSale = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!selectedDomain) return
+ setSaving(true)
+ setError(null)
+
+ try {
+ // Create a listing for this domain
+ await api.request('/listings', {
+ method: 'POST',
+ body: JSON.stringify({
+ domain: selectedDomain.domain,
+ asking_price: listData.asking_price ? parseFloat(listData.asking_price) : null,
+ price_type: listData.price_type,
+ allow_offers: true,
+ }),
+ })
+ setSuccess(`${selectedDomain.domain} is now listed for sale! Go to "For Sale" to verify ownership and publish.`)
+ setShowListModal(false)
+ } catch (err: any) {
+ setError(err.message)
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const formatCurrency = (value: number | null) => {
+ if (value === null) return '—'
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0,
+ }).format(value)
+ }
+
+ const formatDate = (date: string | null) => {
+ if (!date) return '—'
+ return new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ }
+
+ // Tier check
+ const tier = subscription?.tier || 'scout'
+ const canUsePortfolio = tier !== 'scout'
+
+ return (
+
+
+
+ {/* Ambient Background Glow */}
+
+
+
+
+ {/* Header Section */}
+
+
+
+
+ Track your domain investments, valuations, and ROI. Your personal domain asset manager.
+
+
+
+ {canUsePortfolio && (
+
{
+ setFormData({ domain: '', purchase_date: '', purchase_price: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '', tags: '' })
+ setShowAddModal(true)
+ }}
+ className="px-4 py-2 bg-blue-500 text-white font-medium rounded-lg hover:bg-blue-400 transition-all shadow-lg shadow-blue-500/20 flex items-center gap-2"
+ >
+ Add Domain
+
+ )}
+
+
+ {/* Messages */}
+ {error && (
+
+
+
{error}
+
setError(null)}>
+
+ )}
+
+ {success && (
+
+
+
{success}
+
setSuccess(null)}>
+
+ )}
+
+ {/* Paywall */}
+ {!canUsePortfolio && (
+
+
+
+
+
Unlock Portfolio Management
+
+ Track your domain investments, monitor valuations, and calculate ROI. Know exactly how your portfolio is performing.
+
+
+ Upgrade to Trader
+
+
+
+ )}
+
+ {/* Stats Grid */}
+ {canUsePortfolio && summary && (
+
+ 0 ? 'up' : summary.unrealized_profit < 0 ? 'down' : 'neutral'}
+ />
+
+ = 0 ? 'emerald' : 'rose'}
+ trend={summary.unrealized_profit > 0 ? 'up' : summary.unrealized_profit < 0 ? 'down' : 'neutral'}
+ />
+ 0 ? '+' : ''}${summary.overall_roi.toFixed(1)}%`}
+ subValue={`${summary.sold_domains} sold`}
+ icon={Target}
+ color={summary.overall_roi >= 0 ? 'emerald' : 'rose'}
+ trend={summary.overall_roi > 0 ? 'up' : summary.overall_roi < 0 ? 'down' : 'neutral'}
+ />
+
+ )}
+
+ {/* Domains Table */}
+ {canUsePortfolio && (
+
+ {/* Table Header */}
+
+
Domain
+
Cost
+
Value
+
ROI
+
Status
+
Actions
+
+
+ {loading ? (
+
+
+
+ ) : domains.length === 0 ? (
+
+
+
+
+
No domains in portfolio
+
+ Add your first domain to start tracking your investments.
+
+
setShowAddModal(true)}
+ className="text-blue-400 text-sm hover:text-blue-300 transition-colors flex items-center gap-2 font-medium"
+ >
+ Add Domain
+
+
+ ) : (
+
+ {domains.map((domain) => (
+
+
+ {/* Domain */}
+
+
+
+ {domain.domain.charAt(0).toUpperCase()}
+
+
+
{domain.domain}
+
+ {domain.registrar || 'No registrar'}
+ {domain.renewal_date && (
+ • Renews {formatDate(domain.renewal_date)}
+ )}
+
+
+
+
+
+ {/* Cost */}
+
+
{formatCurrency(domain.purchase_price)}
+ {domain.purchase_date && (
+
{formatDate(domain.purchase_date)}
+ )}
+
+
+ {/* Value */}
+
+
+ {domain.is_sold ? formatCurrency(domain.sale_price) : formatCurrency(domain.estimated_value)}
+
+ {domain.is_sold ? (
+
Sold {formatDate(domain.sale_date)}
+ ) : domain.value_updated_at && (
+
Updated {formatDate(domain.value_updated_at)}
+ )}
+
+
+ {/* ROI */}
+
+ {domain.roi !== null ? (
+
= 0 ? "text-emerald-400" : "text-rose-400"
+ )}>
+ {domain.roi > 0 ? '+' : ''}{domain.roi.toFixed(1)}%
+
+ ) : (
+
—
+ )}
+
+
+ {/* Status */}
+
+
+ {domain.is_sold ? 'Sold' : domain.status}
+
+
+
+ {/* Actions */}
+
+ {!domain.is_sold && (
+ <>
+ openListModal(domain)}
+ className="p-2 rounded-lg text-zinc-600 hover:text-amber-400 hover:bg-amber-500/10 transition-all"
+ title="List for sale"
+ >
+
+
+ handleRefreshValue(domain)}
+ className="p-2 rounded-lg text-zinc-600 hover:text-blue-400 hover:bg-blue-500/10 transition-all"
+ title="Refresh value"
+ >
+
+
+ openSellModal(domain)}
+ className="p-2 rounded-lg text-zinc-600 hover:text-emerald-400 hover:bg-emerald-500/10 transition-all"
+ title="Record sale"
+ >
+
+
+ >
+ )}
+ openEditModal(domain)}
+ className="p-2 rounded-lg text-zinc-600 hover:text-white hover:bg-white/10 transition-all"
+ title="Edit"
+ >
+
+
+ handleDelete(domain)}
+ className="p-2 rounded-lg text-zinc-600 hover:text-rose-400 hover:bg-rose-500/10 transition-all"
+ title="Delete"
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+ {/* Add Modal */}
+ {showAddModal && (
+
+
+
+
Add to Portfolio
+
Track a domain you own
+
+
+
+
+
+ )}
+
+ {/* Edit Modal */}
+ {showEditModal && selectedDomain && (
+
+
+
+
Edit {selectedDomain.domain}
+
Update domain information
+
+
+
+
+
+ Purchase Date
+ setFormData({ ...formData, purchase_date: e.target.value })}
+ className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all"
+ />
+
+
+
Purchase Price
+
+
+ setFormData({ ...formData, purchase_price: e.target.value })}
+ className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all font-mono"
+ />
+
+
+
+
+
+
+
+ Notes
+ setFormData({ ...formData, notes: e.target.value })}
+ rows={2}
+ className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-blue-500/50 transition-all resize-none"
+ />
+
+
+
+ setShowEditModal(false)}
+ className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
+ >
+ Cancel
+
+
+ {saving ? : }
+ {saving ? 'Saving...' : 'Save Changes'}
+
+
+
+
+
+ )}
+
+ {/* Sell Modal */}
+ {showSellModal && selectedDomain && (
+
+
+
+
+
+ Record Sale
+
+
+ Congratulations on selling {selectedDomain.domain} !
+
+
+
+
+
+ Sale Date *
+ setSellData({ ...sellData, sale_date: e.target.value })}
+ className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-emerald-500/50 transition-all"
+ />
+
+
+
+
Sale Price *
+
+
+ setSellData({ ...sellData, sale_price: e.target.value })}
+ placeholder="0.00"
+ className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all font-mono text-lg"
+ />
+
+ {selectedDomain.purchase_price && sellData.sale_price && (
+
selectedDomain.purchase_price ? "text-emerald-400" : "text-rose-400"
+ )}>
+ {parseFloat(sellData.sale_price) > selectedDomain.purchase_price ? '📈' : '📉'}
+ {' '}ROI: {(((parseFloat(sellData.sale_price) - selectedDomain.purchase_price) / selectedDomain.purchase_price) * 100).toFixed(1)}%
+ {' '}(${(parseFloat(sellData.sale_price) - selectedDomain.purchase_price).toLocaleString()} profit)
+
+ )}
+
+
+
+ setShowSellModal(false)}
+ className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
+ >
+ Cancel
+
+
+ {saving ? : }
+ {saving ? 'Saving...' : 'Record Sale'}
+
+
+
+
+
+ )}
+
+ {/* List for Sale Modal */}
+ {showListModal && selectedDomain && (
+
+
+
+
+
+ List for Sale
+
+
+ Put {selectedDomain.domain} on the marketplace
+
+
+
+
+
+
Asking Price
+
+
+ setListData({ ...listData, asking_price: e.target.value })}
+ placeholder="Leave empty for 'Make Offer'"
+ className="w-full pl-9 pr-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-zinc-600 focus:outline-none focus:border-amber-500/50 transition-all font-mono text-lg"
+ />
+
+ {selectedDomain.estimated_value && (
+
+ Estimated value: {formatCurrency(selectedDomain.estimated_value)}
+
+ )}
+
+
+
+ Price Type
+ setListData({ ...listData, price_type: e.target.value })}
+ className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:border-amber-500/50 transition-all appearance-none"
+ >
+ Negotiable
+ Fixed Price
+ Make Offer Only
+
+
+
+
+
+ 💡 After creating the listing, you'll need to verify domain ownership via DNS before it goes live on the marketplace.
+
+
+
+
+ setShowListModal(false)}
+ className="flex-1 px-4 py-3 border border-white/10 text-zinc-400 rounded-xl hover:bg-white/5 hover:text-white transition-all font-medium"
+ >
+ Cancel
+
+
+ {saving ? : }
+ {saving ? 'Creating...' : 'Create Listing'}
+
+
+
+
+
+ )}
+
+
+ )
+}
+
diff --git a/frontend/src/app/terminal/radar/page.tsx b/frontend/src/app/terminal/radar/page.tsx
index 89a3213..72e1add 100644
--- a/frontend/src/app/terminal/radar/page.tsx
+++ b/frontend/src/app/terminal/radar/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
+import { useEffect, useState, useMemo, useCallback, useRef, memo } from 'react'
import { useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
@@ -13,33 +13,55 @@ import {
Tag,
Clock,
ExternalLink,
- Sparkles,
Plus,
Zap,
- Crown,
Activity,
Bell,
Search,
- TrendingUp,
ArrowRight,
- Globe,
CheckCircle2,
XCircle,
Loader2,
Wifi,
ShieldAlert,
- BarChart3,
- Command
+ Command,
+ Building2,
+ Calendar,
+ Server,
+ Diamond,
+ Store,
+ TrendingUp
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// ============================================================================
-// SHARED COMPONENTS
+// HELPER FUNCTIONS
// ============================================================================
-function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
- return (
+const formatDate = (dateStr: string | null) => {
+ if (!dateStr) return null
+ return new Date(dateStr).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })
+}
+
+const getDaysUntilExpiration = (dateStr: string | null) => {
+ if (!dateStr) return null
+ const expDate = new Date(dateStr)
+ const now = new Date()
+ const diffTime = expDate.getTime() - now.getTime()
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+ return diffDays
+}
+
+// ============================================================================
+// SHARED COMPONENTS (Matched to Market/Intel)
+// ============================================================================
+
+const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
{children}
@@ -47,44 +69,49 @@ function Tooltip({ children, content }: { children: React.ReactNode; content: st
- )
-}
+))
+Tooltip.displayName = 'Tooltip'
-function StatCard({
+const StatCard = memo(({
label,
value,
subValue,
icon: Icon,
+ highlight,
trend
}: {
label: string
value: string | number
subValue?: string
icon: any
+ highlight?: boolean
trend?: 'up' | 'down' | 'neutral' | 'active'
-}) {
- return (
-
-
+}) => (
+
+
+
+
-
{label}
+
+
+ {label}
+
{value}
{subValue && {subValue} }
+ {highlight && (
+
+ ● LIVE
-
-
-
+ )}
- )
-}
+
+))
+StatCard.displayName = 'StatCard'
// ============================================================================
// TYPES
@@ -105,10 +132,26 @@ interface TrendingTld {
reason: string
}
+interface ListingStats {
+ active: number
+ sold: number
+ draft: number
+ total: number
+}
+
+interface MarketStats {
+ totalAuctions: number
+ endingSoon: number
+}
+
interface SearchResult {
- available: boolean | null
+ domain: string
+ status: string
+ is_available: boolean | null
+ registrar: string | null
+ expiration_date: string | null
+ name_servers: string[] | null
inAuction: boolean
- inMarketplace: boolean
auctionData?: HotAuction
loading: boolean
}
@@ -131,6 +174,8 @@ export default function RadarPage() {
const { toast, showToast, hideToast } = useToast()
const [hotAuctions, setHotAuctions] = useState
([])
const [trendingTlds, setTrendingTlds] = useState([])
+ const [listingStats, setListingStats] = useState({ active: 0, sold: 0, draft: 0, total: 0 })
+ const [marketStats, setMarketStats] = useState({ totalAuctions: 0, endingSoon: 0 })
const [loadingData, setLoadingData] = useState(true)
// Universal Search State
@@ -143,12 +188,29 @@ export default function RadarPage() {
// Load Data
const loadDashboardData = useCallback(async () => {
try {
- const [auctions, trending] = await Promise.all([
- api.getEndingSoonAuctions(5).catch(() => []),
- api.getTrendingTlds().catch(() => ({ trending: [] }))
+ const [endingSoonAuctions, allAuctionsData, trending, listings] = await Promise.all([
+ api.getEndingSoonAuctions(24, 5).catch(() => []),
+ api.getAuctions().catch(() => ({ auctions: [], total: 0 })),
+ api.getTrendingTlds().catch(() => ({ trending: [] })),
+ api.request('/listings/my').catch(() => [])
])
- setHotAuctions(auctions.slice(0, 5))
+
+ // Hot auctions for display (max 5)
+ setHotAuctions(endingSoonAuctions.slice(0, 5))
+
+ // Market stats - total opportunities from ALL auctions
+ setMarketStats({
+ totalAuctions: allAuctionsData.total || allAuctionsData.auctions?.length || 0,
+ endingSoon: endingSoonAuctions.length
+ })
+
setTrendingTlds(trending.trending?.slice(0, 6) || [])
+
+ // Calculate listing stats
+ const active = listings.filter(l => l.status === 'active').length
+ const sold = listings.filter(l => l.status === 'sold').length
+ const draft = listings.filter(l => l.status === 'draft').length
+ setListingStats({ active, sold, draft, total: listings.length })
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
@@ -160,19 +222,30 @@ export default function RadarPage() {
if (isAuthenticated) loadDashboardData()
}, [isAuthenticated, loadDashboardData])
- // Search Logic
- const handleSearch = useCallback(async (domain: string) => {
- if (!domain.trim()) {
+ // Search Logic - identical to DomainChecker on landing page
+ const handleSearch = useCallback(async (domainInput: string) => {
+ if (!domainInput.trim()) {
setSearchResult(null)
return
}
- const cleanDomain = domain.trim().toLowerCase()
- setSearchResult({ available: null, inAuction: false, inMarketplace: false, loading: true })
+ const cleanDomain = domainInput.trim().toLowerCase()
+ setSearchResult({
+ domain: cleanDomain,
+ status: 'checking',
+ is_available: null,
+ registrar: null,
+ expiration_date: null,
+ name_servers: null,
+ inAuction: false,
+ auctionData: undefined,
+ loading: true
+ })
try {
+ // Full domain check (same as DomainChecker component)
const [whoisResult, auctionsResult] = await Promise.all([
- api.checkDomain(cleanDomain, true).catch(() => null),
+ api.checkDomain(cleanDomain).catch(() => null),
api.getAuctions(cleanDomain).catch(() => ({ auctions: [] })),
])
@@ -180,19 +253,29 @@ export default function RadarPage() {
(a: any) => a.domain.toLowerCase() === cleanDomain
)
- const isAvailable = whoisResult && 'is_available' in whoisResult
- ? whoisResult.is_available
- : null
-
setSearchResult({
- available: isAvailable,
+ domain: whoisResult?.domain || cleanDomain,
+ status: whoisResult?.status || 'unknown',
+ is_available: whoisResult?.is_available ?? null,
+ registrar: whoisResult?.registrar || null,
+ expiration_date: whoisResult?.expiration_date || null,
+ name_servers: whoisResult?.name_servers || null,
inAuction: !!auctionMatch,
- inMarketplace: false,
auctionData: auctionMatch,
loading: false,
})
} catch (error) {
- setSearchResult({ available: null, inAuction: false, inMarketplace: false, loading: false })
+ setSearchResult({
+ domain: cleanDomain,
+ status: 'error',
+ is_available: null,
+ registrar: null,
+ expiration_date: null,
+ name_servers: null,
+ inAuction: false,
+ auctionData: undefined,
+ loading: false
+ })
}
}, [])
@@ -236,74 +319,144 @@ export default function RadarPage() {
}, [])
// Computed
- const { availableDomains, totalDomains, greeting, subtitle } = useMemo(() => {
+ const { availableDomains, expiringDomains, recentAlerts, totalDomains, greeting, subtitle } = useMemo(() => {
const available = domains?.filter(d => d.is_available) || []
const total = domains?.length || 0
const hour = new Date().getHours()
const greeting = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening'
+ // Find domains expiring within 30 days
+ const now = new Date()
+ const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
+ const expiring = domains?.filter(d => {
+ if (!d.expiration_date || d.is_available) return false
+ const expDate = new Date(d.expiration_date)
+ return expDate <= thirtyDaysFromNow && expDate > now
+ }) || []
+
+ // Build alerts list with types
+ type AlertItem = {
+ domain: typeof domains[0]
+ type: 'available' | 'expiring' | 'checked'
+ priority: number
+ }
+
+ const alerts: AlertItem[] = []
+
+ // Priority 1: Available domains (highest priority)
+ available.forEach(d => alerts.push({ domain: d, type: 'available', priority: 1 }))
+
+ // Priority 2: Expiring soon
+ expiring.forEach(d => alerts.push({ domain: d, type: 'expiring', priority: 2 }))
+
+ // Priority 3: Recently checked (within last 24h)
+ const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
+ const recentlyChecked = domains?.filter(d => {
+ if (d.is_available || expiring.includes(d)) return false
+ if (!d.last_checked) return false
+ return new Date(d.last_checked) > oneDayAgo
+ }) || []
+ recentlyChecked.slice(0, 3).forEach(d => alerts.push({ domain: d, type: 'checked', priority: 3 }))
+
+ // Sort by priority
+ alerts.sort((a, b) => a.priority - b.priority)
+
let subtitle = ''
if (available.length > 0) subtitle = `${available.length} domain${available.length !== 1 ? 's' : ''} ready to pounce!`
else if (total > 0) subtitle = `Monitoring ${total} domain${total !== 1 ? 's' : ''} for you`
else subtitle = 'Start tracking domains to find opportunities'
- return { availableDomains: available, totalDomains: total, greeting, subtitle }
+ return {
+ availableDomains: available,
+ expiringDomains: expiring,
+ recentAlerts: alerts,
+ totalDomains: total,
+ greeting,
+ subtitle
+ }
}, [domains])
const tickerItems = useTickerItems(trendingTlds, availableDomains, hotAuctions)
return (
{toast && }
- {/* GLOW BACKGROUND */}
-
-
+
+
+ {/* Ambient Background Glow (Matched to Market/Intel) */}
+
+
+
+
+ {/* Header Section */}
+
+
+
+
+
{greeting}{user?.name ? `, ${user.name.split(' ')[0]}` : ''}
+
+
+ {subtitle}
+
-
+ {/* Quick Stats Pills */}
+
+
- {/* 1. TICKER */}
+ {/* Ticker Section */}
{tickerItems.length > 0 && (
-
+
)}
- {/* 2. STAT GRID */}
-
-
+ {/* Metric Grid */}
+
+
0 ? 'up' : 'neutral'}
+ highlight={availableDomains.length > 0}
/>
-
+
-
+
0 ? 'up' : 'neutral'}
+ label="My Listings"
+ value={listingStats.active}
+ subValue={listingStats.sold > 0 ? `${listingStats.sold} Sold` : `${listingStats.draft} Draft`}
+ icon={Tag}
+ trend={listingStats.active > 0 ? 'up' : 'neutral'}
/>
-
-
+
+
- {/* 3. AWARD-WINNING SEARCH (HERO STYLE) */}
-
-
+ {/* Search Hero */}
+
+
+
+
{searchResult.loading ? (
-
-
+
+
Scanning global availability...
- ) : (
-
- {/* Availability Card */}
-
-
- {searchResult.available ? (
-
-
+ ) : searchResult.is_available ? (
+ /* ========== AVAILABLE DOMAIN ========== */
+
+ {/* Header */}
+
+
+
+
- ) : (
-
-
+
+
+
+ {searchResult.domain}
+
+
+ Available
+
- )}
-
-
- {searchResult.available ? 'Available' : 'Registered'}
-
-
- {searchResult.available
- ? 'Ready for immediate registration'
- : 'Currently owned by someone else'}
+
+ It's yours for the taking.
-
- {searchResult.available && (
-
- Register Now
-
- )}
- {/* Auction Card */}
+ {/* Auction Notice */}
{searchResult.inAuction && searchResult.auctionData && (
-
-
-
+
+
+
+
+ Also in auction: ${searchResult.auctionData.current_bid} • {searchResult.auctionData.time_remaining} left
+
-
-
- In Auction
- Live
-
-
- Current Bid: ${searchResult.auctionData.current_bid} • Ends in {searchResult.auctionData.time_remaining}
-
-
+
+ View Auction
+
-
+
+ )}
+
+ {/* CTA */}
+
+
+
+ Grab it now or track it in your watchlist.
+
+
+
+
+
+ ) : (
+ /* ========== TAKEN DOMAIN ========== */
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ {searchResult.domain}
+
+
+ Taken
+
+
+
+ Someone got there first. For now.
+
+
+
+
+
+ {/* Domain Info */}
+ {(searchResult.registrar || searchResult.expiration_date || searchResult.name_servers) && (
+
+
+ {searchResult.registrar && (
+
+
+
+
+
+
Registrar
+
{searchResult.registrar}
+
+
+ )}
+
+ {searchResult.expiration_date && (
+
+
+
+
+
+
Expires
+
+ {formatDate(searchResult.expiration_date)}
+ {getDaysUntilExpiration(searchResult.expiration_date) !== null && (
+
+ ({getDaysUntilExpiration(searchResult.expiration_date)} days)
+
+ )}
+
+
+
+ )}
+
+ {searchResult.name_servers && searchResult.name_servers.length > 0 && (
+
+
+
+
+
+
Name Servers
+
+ {searchResult.name_servers.slice(0, 2).join(' · ')}
+ {searchResult.name_servers.length > 2 && (
+ +{searchResult.name_servers.length - 2}
+ )}
+
+
+
+ )}
+
+
+ )}
+
+ {/* Auction Notice */}
+ {searchResult.inAuction && searchResult.auctionData && (
+
+
+
+
+
+
+
+
+ In Auction
+ Live
+
+
+ Current Bid: ${searchResult.auctionData.current_bid} • {searchResult.auctionData.time_remaining} left
+
+
+
Place Bid
+
)}
- {/* Add to Watchlist */}
-
+ {/* Watchlist CTA */}
+
+
+
+
+ We'll alert you the moment it drops.
+
{addingToWatchlist ? : }
- Add to Pounce Watchlist
+ Track This
+
)}
@@ -458,27 +749,34 @@ export default function RadarPage() {
)}
-
{/* 4. SPLIT VIEW: PULSE & ALERTS */}
{/* MARKET PULSE */}
-
-
+
+
+
+
Market Pulse
+
Live auctions ending soon
+
-
+
View All
-
+
{loadingData ? (
-
Loading market data...
+
+
+ Loading market data...
+
) : hotAuctions.length > 0 ? (
hotAuctions.map((auction, i) => (
-
-
+
+
+ {auction.platform.substring(0, 2).toUpperCase()}
+
-
+
{auction.domain}
- {auction.platform} • {auction.time_remaining} left
+ {auction.platform} • {auction.time_remaining} left
@@ -506,8 +806,8 @@ export default function RadarPage() {
))
) : (
-
-
+
+
No live auctions right now
)}
@@ -515,49 +815,90 @@ export default function RadarPage() {
{/* WATCHLIST ACTIVITY */}
-
-
+
+
+
+
+
Recent Alerts
+
Status changes on watchlist
+
-
+
Manage
-
- {availableDomains.length > 0 ? (
- availableDomains.slice(0, 5).map((domain) => (
-
-
-
-
-
+
+ {recentAlerts.length > 0 ? (
+ recentAlerts.slice(0, 5).map((alert, idx) => (
+
+
+ {alert.type === 'available' ? (
+
+ ) : alert.type === 'expiring' ? (
+
+
+
+ ) : (
+
+ )}
+
-
{domain.name}
-
Available for Registration
+
{alert.domain.name}
+
+ {alert.type === 'available' && (
+ <>
+ Available Now
+ >
+ )}
+ {alert.type === 'expiring' && `Expires ${new Date(alert.domain.expiration_date!).toLocaleDateString()}`}
+ {alert.type === 'checked' && `Checked ${alert.domain.last_checked ? new Date(alert.domain.last_checked).toLocaleTimeString() : ''}`}
+
+
-
+
+ {alert.type === 'available' ? (
Register
+ ) : alert.type === 'expiring' ? (
+
+ Expiring
+
+ ) : (
+
+ {alert.domain.status}
+
+ )}
))
) : totalDomains > 0 ? (
-
-
-
All watched domains are taken
+
+
+
All watched domains are stable
+
No alerts at this time
) : (
-
-
+
+
Your watchlist is empty
Use search to add domains
@@ -565,6 +906,7 @@ export default function RadarPage() {
+
diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx
index 3c11f5b..511a03e 100755
--- a/frontend/src/app/terminal/watchlist/page.tsx
+++ b/frontend/src/app/terminal/watchlist/page.tsx
@@ -12,7 +12,6 @@ import {
Loader2,
Bell,
BellOff,
- ExternalLink,
Eye,
Sparkles,
ArrowUpRight,
@@ -23,131 +22,167 @@ import {
ShoppingCart,
HelpCircle,
Search,
- Filter,
- CheckCircle2,
Globe,
Clock,
Calendar,
- MoreVertical,
- ChevronDown,
- ArrowRight
+ ArrowRight,
+ CheckCircle2,
+ XCircle,
+ Wifi,
+ Lock,
+ TrendingDown,
+ Zap,
+ Diamond
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
// ============================================================================
-// SHARED COMPONENTS
+// SHARED COMPONENTS (Matched to Market/Intel/Radar)
// ============================================================================
-function Tooltip({ children, content }: { children: React.ReactNode; content: string }) {
- return (
+const Tooltip = memo(({ children, content }: { children: React.ReactNode; content: string }) => (
{children}
{content}
- {/* Arrow */}
- )
-}
+))
+Tooltip.displayName = 'Tooltip'
-function StatCard({
+const StatCard = memo(({
label,
value,
subValue,
icon: Icon,
+ highlight,
trend
}: {
label: string
value: string | number
subValue?: string
icon: any
+ highlight?: boolean
trend?: 'up' | 'down' | 'neutral' | 'active'
-}) {
- return (
-
+}) => (
+
-
+
{label}
{value}
{subValue && {subValue} }
- {trend && (
-
- {trend === 'active' ? '● LIVE MONITORING' : trend === 'up' ? '▲ POSITIVE' : '▼ NEGATIVE'}
+ {highlight && (
+
+ ● LIVE
)}
- )
-}
+))
+StatCard.displayName = 'StatCard'
// Health status badge configuration
const healthStatusConfig: Record
= {
healthy: {
label: 'Online',
color: 'text-emerald-400',
+ bgColor: 'bg-emerald-500/10 border-emerald-500/20',
icon: Activity,
description: 'Domain is active and reachable',
- dot: 'bg-emerald-400'
},
weakening: {
label: 'Issues',
color: 'text-amber-400',
+ bgColor: 'bg-amber-500/10 border-amber-500/20',
icon: AlertTriangle,
description: 'Warning signs detected',
- dot: 'bg-amber-400'
},
parked: {
label: 'Parked',
color: 'text-blue-400',
+ bgColor: 'bg-blue-500/10 border-blue-500/20',
icon: ShoppingCart,
description: 'Domain is parked/for sale',
- dot: 'bg-blue-400'
},
critical: {
- label: 'Offline',
+ label: 'Critical',
color: 'text-rose-400',
+ bgColor: 'bg-rose-500/10 border-rose-500/20',
icon: AlertTriangle,
- description: 'Domain is offline/error',
- dot: 'bg-rose-400'
+ description: 'Domain may be dropping soon',
},
unknown: {
label: 'Unknown',
color: 'text-zinc-400',
+ bgColor: 'bg-zinc-800 border-zinc-700',
icon: HelpCircle,
- description: 'Status unknown',
- dot: 'bg-zinc-600'
+ description: 'Health check pending',
},
}
-type FilterStatus = 'watching' | 'portfolio' | 'available'
+type FilterTab = 'all' | 'available' | 'expiring' | 'critical'
+
+// ============================================================================
+// HELPER FUNCTIONS
+// ============================================================================
+
+function getDaysUntilExpiry(expirationDate: string | null): number | null {
+ if (!expirationDate) return null
+ const expDate = new Date(expirationDate)
+ const now = new Date()
+ const diffTime = expDate.getTime() - now.getTime()
+ return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
+}
+
+function formatExpiryDate(expirationDate: string | null): string {
+ if (!expirationDate) return '—'
+ return new Date(expirationDate).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ })
+}
+
+function getTimeAgo(date: string | null): string {
+ if (!date) return 'Never'
+ const now = new Date()
+ const past = new Date(date)
+ const diffMs = now.getTime() - past.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return 'Just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ if (diffDays < 7) return `${diffDays}d ago`
+ return formatExpiryDate(date)
+}
// ============================================================================
// MAIN PAGE
// ============================================================================
export default function WatchlistPage() {
- const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
+ const { domains, addDomain, deleteDomain, refreshDomain, updateDomain, subscription } = useStore()
const { toast, showToast, hideToast } = useToast()
const [newDomain, setNewDomain] = useState('')
@@ -155,37 +190,75 @@ export default function WatchlistPage() {
const [refreshingId, setRefreshingId] = useState(null)
const [deletingId, setDeletingId] = useState(null)
const [togglingNotifyId, setTogglingNotifyId] = useState(null)
- const [filterStatus, setFilterStatus] = useState('watching')
+ const [filterTab, setFilterTab] = useState('all')
const [searchQuery, setSearchQuery] = useState('')
// Health check state
const [healthReports, setHealthReports] = useState>({})
const [loadingHealth, setLoadingHealth] = useState>({})
const [selectedHealthDomainId, setSelectedHealthDomainId] = useState(null)
+
// Memoized stats
- const stats = useMemo(() => ({
- availableCount: domains?.filter(d => d.is_available).length || 0,
- watchingCount: domains?.filter(d => !d.is_available).length || 0,
- domainsUsed: domains?.length || 0,
- domainLimit: subscription?.domain_limit || 5,
- }), [domains, subscription?.domain_limit])
+ const stats = useMemo(() => {
+ const available = domains?.filter(d => d.is_available) || []
+ const expiringSoon = domains?.filter(d => {
+ if (d.is_available || !d.expiration_date) return false
+ const days = getDaysUntilExpiry(d.expiration_date)
+ return days !== null && days <= 30 && days > 0
+ }) || []
+ const critical = Object.values(healthReports).filter(h => h.status === 'critical').length
+
+ return {
+ total: domains?.length || 0,
+ available: available.length,
+ expiringSoon: expiringSoon.length,
+ critical,
+ limit: subscription?.domain_limit || 5,
+ }
+ }, [domains, subscription?.domain_limit, healthReports])
- const canAddMore = stats.domainsUsed < stats.domainLimit
+ const canAddMore = stats.total < stats.limit || stats.limit === -1
// Memoized filtered domains
const filteredDomains = useMemo(() => {
if (!domains) return []
return domains.filter(domain => {
+ // Search filter
if (searchQuery && !domain.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false
}
- if (filterStatus === 'available' && !domain.is_available) return false
- // 'portfolio' logic would go here
+
+ // Tab filter
+ switch (filterTab) {
+ case 'available':
+ return domain.is_available
+ case 'expiring':
+ if (domain.is_available || !domain.expiration_date) return false
+ const days = getDaysUntilExpiry(domain.expiration_date)
+ return days !== null && days <= 30 && days > 0
+ case 'critical':
+ const health = healthReports[domain.id]
+ return health?.status === 'critical' || health?.status === 'weakening'
+ default:
return true
+ }
+ }).sort((a, b) => {
+ // Sort available first, then by expiry date
+ if (a.is_available && !b.is_available) return -1
+ if (!a.is_available && b.is_available) return 1
+
+ // Then by expiry (soonest first)
+ const daysA = getDaysUntilExpiry(a.expiration_date)
+ const daysB = getDaysUntilExpiry(b.expiration_date)
+ if (daysA !== null && daysB !== null) return daysA - daysB
+ if (daysA !== null) return -1
+ if (daysB !== null) return 1
+
+ return a.name.localeCompare(b.name)
})
- }, [domains, searchQuery, filterStatus])
+ }, [domains, searchQuery, filterTab, healthReports])
// Callbacks
const handleAddDomain = useCallback(async (e: React.FormEvent) => {
@@ -234,13 +307,15 @@ export default function WatchlistPage() {
setTogglingNotifyId(id)
try {
await api.updateDomainNotify(id, !currentState)
+ // Instant optimistic update
+ updateDomain(id, { notify_on_available: !currentState })
showToast(!currentState ? 'Notifications enabled' : 'Notifications disabled', 'success')
} catch (err: any) {
showToast(err.message || 'Failed to update', 'error')
} finally {
setTogglingNotifyId(null)
}
- }, [showToast])
+ }, [showToast, updateDomain])
const handleHealthCheck = useCallback(async (domainId: number) => {
if (loadingHealth[domainId]) return
@@ -257,6 +332,29 @@ export default function WatchlistPage() {
}
}, [loadingHealth, showToast])
+
+ // Load health data for all domains on mount
+ useEffect(() => {
+ const loadHealthData = async () => {
+ if (!domains || domains.length === 0) return
+
+ // Load health for registered domains only (not available ones)
+ const registeredDomains = domains.filter(d => !d.is_available)
+
+ for (const domain of registeredDomains.slice(0, 10)) { // Limit to first 10 to avoid overload
+ try {
+ const report = await api.getDomainHealth(domain.id)
+ setHealthReports(prev => ({ ...prev, [domain.id]: report }))
+ } catch {
+ // Silently fail - health data is optional
+ }
+ await new Promise(r => setTimeout(r, 200)) // Small delay
+ }
+ }
+
+ loadHealthData()
+ }, [domains])
+
return (
@@ -278,19 +376,21 @@ export default function WatchlistPage() {
Watchlist
- Monitor availability, expiration dates, and health metrics for your critical domains.
+ Monitor availability, health, and expiration dates for your tracked domains.
{/* Quick Stats Pills */}
-
-
- {stats.watchingCount} Active
+ {stats.available > 0 && (
+
+
+ {stats.available} Available!
+ )}
-
- {stats.availableCount} Available
+
+ Auto-Monitoring
@@ -298,50 +398,67 @@ export default function WatchlistPage() {
{/* Metric Grid */}
0}
+ trend={stats.available > 0 ? 'up' : 'active'}
/>
0 ? 'up' : 'neutral'}
+ trend={stats.available > 0 ? 'up' : 'neutral'}
/>
0 ? 'down' : 'neutral'}
/>
= stats.domainLimit ? 'down' : 'neutral'}
+ label="Health Issues"
+ value={stats.critical}
+ subValue="Need attention"
+ icon={AlertTriangle}
+ trend={stats.critical > 0 ? 'down' : 'neutral'}
/>
{/* Control Bar */}
- {/* Filter Pills */}
+ {/* Filter Tabs */}
- {(['watching', 'available'] as const).map((tab) => (
+ {([
+ { key: 'all', label: 'All', count: stats.total },
+ { key: 'available', label: 'Available', count: stats.available },
+ { key: 'expiring', label: 'Expiring', count: stats.expiringSoon },
+ { key: 'critical', label: 'Issues', count: stats.critical },
+ ] as const).map((tab) => (
setFilterStatus(tab)}
+ key={tab.key}
+ onClick={() => setFilterTab(tab.key)}
className={clsx(
- "px-4 py-1.5 rounded-md text-xs font-medium transition-all",
- filterStatus === tab
+ "px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-2",
+ filterTab === tab.key
? "bg-zinc-800 text-white shadow-sm"
: "text-zinc-400 hover:text-zinc-200 hover:bg-white/5"
)}
>
- {tab.charAt(0).toUpperCase() + tab.slice(1)}
+ {tab.label}
+ {tab.count > 0 && (
+
+ {tab.count}
+
+ )}
))}
@@ -352,7 +469,7 @@ export default function WatchlistPage() {
type="text"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
- placeholder="Add domain to watch (e.g. apple.com)..."
+ placeholder="Add domain (e.g. apple.com)..."
className="w-full bg-black/50 border border-white/10 rounded-lg pl-10 pr-12 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all"
/>
@@ -372,7 +489,7 @@ export default function WatchlistPage() {
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
- placeholder="Filter watchlist..."
+ placeholder="Filter domains..."
className="w-full bg-black/50 border border-white/10 rounded-lg pl-9 pr-4 py-2 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-white/20 transition-all"
/>
@@ -383,7 +500,7 @@ export default function WatchlistPage() {
-
Limit reached. Upgrade plan to track more domains.
+
Limit reached ({stats.total}/{stats.limit}). Upgrade to track more.
Upgrade
@@ -394,12 +511,15 @@ export default function WatchlistPage() {
{/* Data Grid */}
{/* Table Header */}
+
+
-
Domain
-
Status
-
Health
-
Alerts
-
Actions
+
Domain
+
Status
+
Health
+
Expires
+
Alerts
+
Actions
{filteredDomains.length === 0 ? (
@@ -408,12 +528,12 @@ export default function WatchlistPage() {
- {searchQuery ? "No matches found" : "Watchlist is empty"}
+ {searchQuery ? "No matches found" : filterTab !== 'all' ? `No ${filterTab} domains` : "Watchlist is empty"}
- {searchQuery ? "Try adjusting your filters." : "Start by adding domains you want to track above."}
+ {searchQuery ? "Try adjusting your filter." : "Start by adding domains you want to track."}
- {!searchQuery && (
+ {!searchQuery && filterTab === 'all' && (
document.querySelector('input')?.focus()}
className="text-emerald-400 text-sm hover:text-emerald-300 transition-colors flex items-center gap-2"
@@ -426,104 +546,78 @@ export default function WatchlistPage() {
{filteredDomains.map((domain) => {
const health = healthReports[domain.id]
- const healthConfig = health ? healthStatusConfig[health.status] : null
+ const healthConfig = health ? healthStatusConfig[health.status] : healthStatusConfig.unknown
+ const daysUntilExpiry = getDaysUntilExpiry(domain.expiration_date)
+ const isExpiringSoon = daysUntilExpiry !== null && daysUntilExpiry <= 30 && daysUntilExpiry > 0
+ const isExpired = daysUntilExpiry !== null && daysUntilExpiry <= 0
return (
- {/* Mobile Layout (Visible only on mobile) */}
-
-
-
-
{domain.name}
-
-
- {domain.is_available ? 'AVAILABLE' : 'TAKEN'}
-
-
-
-
handleDelete(domain.id, domain.name)}
- className="p-2 text-zinc-500 hover:text-rose-400 transition-colors"
- >
-
-
-
-
-
-
handleHealthCheck(domain.id)}
- className="text-xs text-zinc-400 flex items-center gap-1.5 hover:text-white"
- >
-
- Check Health
-
-
handleToggleNotify(domain.id, domain.notify_on_available)}
- className={clsx(
- "text-xs flex items-center gap-1.5",
- domain.notify_on_available ? "text-emerald-400" : "text-zinc-500"
- )}
- >
- {domain.notify_on_available ? : }
- {domain.notify_on_available ? 'Alerts On' : 'Alerts Off'}
-
-
-
-
- {/* Desktop Layout */}
{/* Domain */}
-
+
- {domain.is_available &&
}
+ {domain.is_available && (
+
+ )}
+
{domain.name}
+ {domain.registrar && (
+
+ {domain.registrar}
+
+ )}
+
{/* Status */}
-
-
- {domain.is_available ? 'Available' : 'Registered'}
+
+ {domain.is_available ? (
+
+
+ Available
+ ) : (
+
+
+ Registered
+
+ )}
{/* Health */}
-
- {healthConfig ? (
+
+ {domain.is_available ? (
+ —
+ ) : health ? (
setSelectedHealthDomainId(domain.id)}
className={clsx(
- "flex items-center gap-2 px-2 py-1 rounded hover:bg-white/5 transition-colors",
+ "flex items-center gap-1.5 px-2 py-1 rounded border text-xs font-medium transition-colors hover:bg-white/5",
+ healthConfig.bgColor,
healthConfig.color
)}
>
-
- {healthConfig.label}
+
+ {healthConfig.label}
) : (
-
+
handleHealthCheck(domain.id)}
disabled={loadingHealth[domain.id]}
- className="text-zinc-600 hover:text-zinc-400 transition-colors"
+ className="text-zinc-600 hover:text-zinc-400 transition-colors p-1"
>
{loadingHealth[domain.id] ? (
@@ -535,9 +629,40 @@ export default function WatchlistPage() {
)}
+ {/* Expiry */}
+
+ {domain.is_available ? (
+
—
+ ) : domain.expiration_date ? (
+
+
+ {formatExpiryDate(domain.expiration_date)}
+
+ {daysUntilExpiry !== null && (
+
+ {isExpired ? 'EXPIRED' : `${daysUntilExpiry}d left`}
+
+ )}
+
+ ) : (
+
+
+
+ Not public
+
+
+ )}
+
+
{/* Alerts */}
-
-
+
+
handleToggleNotify(domain.id, domain.notify_on_available)}
disabled={togglingNotifyId === domain.id}
@@ -560,10 +685,11 @@ export default function WatchlistPage() {
{/* Actions */}
-
-
+
+
handleRefresh(domain.id)}
+ disabled={refreshingId === domain.id}
className={clsx(
"p-1.5 rounded-lg text-zinc-500 hover:text-white hover:bg-white/10 transition-colors",
refreshingId === domain.id && "animate-spin text-emerald-400"
@@ -573,32 +699,50 @@ export default function WatchlistPage() {
-
+
handleDelete(domain.id, domain.name)}
+ disabled={deletingId === domain.id}
className="p-1.5 rounded-lg text-zinc-500 hover:text-rose-400 hover:bg-rose-400/10 transition-colors"
>
+ {deletingId === domain.id ? (
+
+ ) : (
+ )}
{domain.is_available && (
-
Buy
-
)}
)
})}
+ )}
+
+
+
+
+ {/* Subtle Footer Info */}
+
+
+
+ Checks: {subscription?.tier === 'tycoon' ? '10min' : subscription?.tier === 'trader' ? 'hourly' : 'daily'}
+
+ {subscription?.tier !== 'tycoon' && (
+
+ Upgrade
+
)}
@@ -615,7 +759,7 @@ export default function WatchlistPage() {
)
}
-// Health Report Modal Component - memoized
+// Health Report Modal Component
const HealthReportModal = memo(function HealthReportModal({
report,
onClose
@@ -626,19 +770,24 @@ const HealthReportModal = memo(function HealthReportModal({
const config = healthStatusConfig[report.status]
const Icon = config.icon
+ // Safely access nested properties
+ const dns = report.dns || {}
+ const http = report.http || {}
+ const ssl = report.ssl || {}
+
return (
e.stopPropagation()}
>
{/* Header */}
-
+
@@ -676,74 +825,175 @@ const HealthReportModal = memo(function HealthReportModal({
{/* Check Results */}
-
+
- {/* Section: Infrastructure */}
+ {/* Infrastructure */}
Infrastructure
+ {/* DNS */}
DNS Status
-
-
- {report.dns?.has_ns ? '● Active' : '○ Missing'}
-
+
+ {dns.has_ns ? '● Active' : '○ No Records'}
+ {dns.nameservers && dns.nameservers.length > 0 && (
+
+ {dns.nameservers[0]}
+
+ )}
+
+ {/* Web Server */}
Web Server
-
-
- {report.http?.is_reachable ? `● HTTP ${report.http?.status_code}` : '○ Unreachable'}
-
+
+ {http.is_reachable
+ ? `● HTTP ${http.status_code || 200}`
+ : http.error
+ ? `○ ${http.error}`
+ : '○ Unreachable'
+ }
+ {http.content_length !== undefined && http.content_length > 0 && (
+
+ {(http.content_length / 1024).toFixed(1)} KB
+
+ )}
+
+ {/* A Record */}
+
+
A Record
+
+ {dns.has_a ? '● Configured' : '○ Not set'}
- {/* Section: Security */}
+ {/* MX Record */}
+
+
Mail (MX)
+
+ {dns.has_mx ? '● Configured' : '○ Not set'}
+
+
+
+
+
+ {/* Security */}
-
+
Security
-
-
+
+
SSL Certificate
- {report.ssl?.is_valid ? 'SECURE' : 'INSECURE'}
+ {ssl.has_certificate && ssl.is_valid ? 'Secure' :
+ ssl.has_certificate ? 'Invalid' : 'None'}
- {report.ssl?.days_until_expiry && (
-
- Expires in
{report.ssl.days_until_expiry} days
+
+ {ssl.issuer && (
+
+ Issuer
+ {ssl.issuer}
)}
+
+ {ssl.days_until_expiry !== undefined && ssl.days_until_expiry !== null && (
+
+ Expires in
+
+ {ssl.days_until_expiry} days
+
+
+ )}
+
+ {ssl.error && (
+
+ {ssl.error}
+
+ )}
- {/* Signals & Recommendations */}
- {((report.signals?.length || 0) > 0 || (report.recommendations?.length || 0) > 0) && (
-
- {(report.signals?.length || 0) > 0 && (
+ {/* Parking Detection */}
+ {(dns.is_parked || http.is_parked) && (
-
Signals
+
+ Parking Detected
+
+
+
+ This domain appears to be parked or for sale.
+ {dns.parking_provider && (
+ Provider: {dns.parking_provider}
+ )}
+
+ {http.parking_keywords && http.parking_keywords.length > 0 && (
+
+ Keywords: {http.parking_keywords.slice(0, 3).join(', ')}
+
+ )}
+
+
+ )}
+
+ {/* Signals */}
+ {report.signals && report.signals.length > 0 && (
+
+
+ Signals
+
- {report.signals?.map((signal, i) => (
-
-
+ {report.signals.map((signal, i) => (
+
{signal}
))}
)}
+
+ {/* Recommendations */}
+ {report.recommendations && report.recommendations.length > 0 && (
+
+
+ Recommendations
+
+
+ {report.recommendations.map((rec, i) => (
+
+ {rec}
+
+ ))}
+
)}
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index 0c19d2d..2df82f4 100755
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -21,6 +21,7 @@ import {
X,
Sparkles,
Tag,
+ Briefcase,
} from 'lucide-react'
import { useState, useEffect } from 'react'
import clsx from 'clsx'
@@ -105,9 +106,15 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
icon: Eye,
badge: availableCount || null,
},
+ {
+ href: '/terminal/portfolio',
+ label: 'PORTFOLIO',
+ icon: Briefcase,
+ badge: null,
+ },
{
href: '/terminal/listing',
- label: 'LISTING',
+ label: 'FOR SALE',
icon: Tag,
badge: null,
},
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 1686610..87e3604 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -323,6 +323,17 @@ class ApiClient {
})
}
+ async updateDomainExpiry(id: number, expirationDate: string | null) {
+ return this.request<{
+ id: number
+ name: string
+ expiration_date: string | null
+ }>(`/domains/${id}/expiry`, {
+ method: 'PATCH',
+ body: JSON.stringify({ expiration_date: expirationDate }),
+ })
+ }
+
// Marketplace Listings (Pounce Direct)
async getMarketplaceListings() {
// TODO: Implement backend endpoint for marketplace listings
diff --git a/frontend/src/lib/store.ts b/frontend/src/lib/store.ts
index 6cfa308..c9b1f5b 100644
--- a/frontend/src/lib/store.ts
+++ b/frontend/src/lib/store.ts
@@ -70,6 +70,7 @@ interface AppState {
addDomain: (name: string) => Promise
deleteDomain: (id: number) => Promise
refreshDomain: (id: number) => Promise
+ updateDomain: (id: number, updates: Partial) => void
fetchSubscription: () => Promise
}
@@ -175,6 +176,13 @@ export const useStore = create((set, get) => ({
set({ domains })
},
+ updateDomain: (id, updates) => {
+ const domains = get().domains.map((d) =>
+ d.id === id ? { ...d, ...updates } : d
+ )
+ set({ domains })
+ },
+
// Subscription actions
fetchSubscription: async () => {
try {
diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md
index 65ea1a2..d607ca4 100644
--- a/memory-bank/activeContext.md
+++ b/memory-bank/activeContext.md
@@ -1,35 +1,72 @@
-# DomainWatch - Active Context
+# Pounce - Active Context
## Current Status
-Project structure and core functionality implemented.
+Pounce Terminal fully functional with complete monitoring & notification system.
## Completed
- [x] Backend structure with FastAPI
-- [x] Database models (User, Domain, DomainCheck, Subscription)
-- [x] Domain checker service (WHOIS + DNS)
-- [x] Authentication system (JWT)
+- [x] Database models (User, Domain, DomainCheck, Subscription, TLDPrice, DomainHealthCache)
+- [x] Domain checker service (WHOIS + RDAP + DNS)
+- [x] Domain health checker (DNS, HTTP, SSL layers)
+- [x] Authentication system (JWT + OAuth)
- [x] API endpoints for domain management
-- [x] Daily scheduler for domain checks
-- [x] Next.js frontend with dark theme
-- [x] Public domain checker component
-- [x] User dashboard for domain monitoring
-- [x] Pricing page with tiers
+- [x] Tiered scheduler for domain checks (Scout=daily, Trader=hourly, Tycoon=10min)
+- [x] Next.js frontend with dark terminal theme
+- [x] Pounce Terminal with all modules (Radar, Market, Intel, Watchlist, Listing)
+- [x] Intel page with tier-gated features
+- [x] TLD price scraping from 5 registrars (Porkbun, Namecheap, Cloudflare, GoDaddy, Dynadot)
+- [x] **Watchlist with automatic monitoring & alerts**
+- [x] **Health check overlays with complete DNS/HTTP/SSL details**
+- [x] **Instant alert toggle (no refresh needed)**
+
+## Recent Changes (Dec 2024)
+
+### Watchlist & Monitoring
+1. **Automatic domain checks**: Runs based on subscription tier
+2. **Email alerts when domain becomes available**: Sends immediately
+3. **Expiry warnings**: Weekly check for domains expiring in <30 days
+4. **Health status monitoring**: Daily health checks with caching
+5. **Weekly digest emails**: Summary every Sunday
+
+### Email Notifications Implemented
+| Alert Type | Trigger |
+|------------|---------|
+| Domain Available | Domain becomes free |
+| Expiry Warning | <30 days until expiry |
+| Health Critical | Domain goes offline |
+| Price Change | TLD price changes >5% |
+| Sniper Match | Auction matches criteria |
+| Weekly Digest | Every Sunday |
+
+### UI Improvements
+1. **Instant alert toggle**: Uses Zustand store for optimistic updates
+2. **Less prominent check frequency**: Subtle footer instead of prominent banner
+3. **Health modals**: Show complete DNS, HTTP, SSL details
+4. **"Not public" for private registries**: .ch/.de show lock icon with tooltip
## Next Steps
-1. Install dependencies and test locally
-2. Add email notifications when domain becomes available
-3. Payment integration (Stripe recommended)
-4. Add more detailed WHOIS information display
-5. Domain check history page
+1. **Configure SMTP on server** - Required for email alerts to work
+2. **Test email delivery** - Verify alerts are sent correctly
+3. **Consider SMS alerts** - Would require Twilio integration
+4. **Monitor scheduler health** - Check logs for job execution
+
+## Server Deployment Checklist
+- [ ] Set `SMTP_*` environment variables (see `env.example`)
+- [ ] Set `STRIPE_*` for payments
+- [ ] Set `GOOGLE_*` and `GITHUB_*` for OAuth
+- [ ] Run `python scripts/init_db.py`
+- [ ] Run `python scripts/seed_tld_prices.py`
+- [ ] Start with PM2: `pm2 start "uvicorn app.main:app --host 0.0.0.0 --port 8000"`
## Design Decisions
-- **Dark theme** with green accent color (#22c55e)
-- **Minimalist UI** with outlined icons only
-- **No emojis** - professional appearance
-- **Card-based layout** for domain list
+- **Dark terminal theme** with emerald accent (#10b981)
+- **Tier-gated features**: Scout (free), Trader ($9), Tycoon ($29)
+- **Real data priority**: Always prefer DB data over simulations
+- **Multiple registrar sources**: For accurate price comparison
+- **Optimistic UI updates**: Instant feedback without API round-trip
## Known Considerations
-- WHOIS rate limiting: Added 0.5s delay between checks
-- Some TLDs may not return complete WHOIS data
-- DNS-only check is faster but less reliable
-
+- Email alerts require SMTP configuration
+- Some TLDs (.ch, .de) don't publish expiration dates publicly
+- SSL checks may fail on local dev (certificate chain issues)
+- Scheduler starts automatically with uvicorn