-
- {a.domain}
-
+ render: (item: MarketItem) => (
+
+
+ {item.isPounce && }
+ {item.domain}
+ {item.verified && (
+ ✓ Verified
+ )}
+
-
- {a.age_years &&
{a.age_years}y }
+
),
},
- {
- key: 'platform',
- header: 'Platform',
- hideOnMobile: true,
- render: (a: Auction) => (
-
-
- {a.age_years && (
-
- {a.age_years}y
-
- )}
-
- ),
- },
- {
- key: 'bid_asc',
- header: 'Bid',
- sortable: true,
- align: 'right' as const,
- render: (a: Auction) => (
-
-
{formatCurrency(a.current_bid)}
- {a.buy_now_price && (
-
Buy: {formatCurrency(a.buy_now_price)}
- )}
-
- ),
- },
{
key: 'score',
- header: 'Deal Score',
- sortable: true,
+ header: 'Pounce Score',
align: 'center' as const,
- hideOnMobile: true,
- render: (a: Auction) => {
- if (activeTab === 'opportunities') {
- const oppData = getOpportunityData(a.domain)
- if (oppData) {
- return (
-
- {oppData.opportunity_score}
-
- )
- }
- }
-
- if (!isPaidUser) {
+ render: (item: MarketItem) => {
+ if (!isPaidUser && !item.isPounce) {
return (
)
}
- const score = calculateDealScore(a)
return (
-
-
= 75 ? "bg-accent/20 text-accent" :
- score >= 50 ? "bg-amber-500/20 text-amber-400" :
- "bg-foreground/10 text-foreground-muted"
- )}>
- {score}
-
- {score >= 75 &&
Undervalued }
+
+ {item.pounceScore}
+
+ )
+ },
+ },
+ {
+ key: 'price',
+ header: 'Price / Bid',
+ align: 'right' as const,
+ render: (item: MarketItem) => (
+
+
+ {formatCurrency(item.price)}
+
+ {item.type === 'auction' && (
+ (Bid)
+ )}
+
+ ),
+ },
+ {
+ key: 'status',
+ header: 'Status / Time',
+ align: 'center' as const,
+ hideOnMobile: true,
+ render: (item: MarketItem) => {
+ if (item.type === 'direct') {
+ return (
+
+
+ Instant
+
+ )
+ }
+
+ const isUrgent = item.timeRemaining?.includes('m') && !item.timeRemaining?.includes('h')
+ const isWarning = item.timeRemaining?.includes('h') && parseInt(item.timeRemaining) < 2
+
+ return (
+
+
+ {item.timeRemaining}
)
},
},
{
- key: 'bids',
- header: 'Bids',
- sortable: true,
- align: 'right' as const,
+ key: 'source',
+ header: 'Source',
+ align: 'center' as const,
hideOnMobile: true,
- render: (a: Auction) => (
-
= 20 ? "text-accent" : a.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted"
- )}>
- {a.num_bids}
- {a.num_bids >= 20 && }
-
- ),
- },
- {
- key: 'ending',
- header: 'Time Left',
- sortable: true,
- align: 'right' as const,
- hideOnMobile: true,
- render: (a: Auction) => (
-
- {a.time_remaining}
-
- ),
+ render: (item: MarketItem) =>
,
},
{
key: 'actions',
header: '',
align: 'right' as const,
- render: (a: Auction) => (
+ render: (item: MarketItem) => (
{ e.preventDefault(); handleTrackDomain(a.domain) }}
- disabled={trackedDomains.has(a.domain) || trackingInProgress === a.domain}
+ onClick={(e) => { e.preventDefault(); handleTrackDomain(item.domain) }}
+ disabled={trackedDomains.has(item.domain) || trackingInProgress === item.domain}
className={clsx(
- "inline-flex items-center justify-center w-8 h-8 rounded-lg transition-all",
- trackedDomains.has(a.domain)
+ "inline-flex items-center justify-center w-9 h-9 rounded-xl transition-all",
+ trackedDomains.has(item.domain)
? "bg-accent/20 text-accent cursor-default"
: "bg-foreground/5 text-foreground-subtle hover:bg-accent/10 hover:text-accent"
)}
- title={trackedDomains.has(a.domain) ? 'Already tracked' : 'Add to Watchlist'}
+ title={trackedDomains.has(item.domain) ? 'Already tracked' : 'Add to Watchlist'}
>
- {trackingInProgress === a.domain ? (
+ {trackingInProgress === item.domain ? (
- ) : trackedDomains.has(a.domain) ? (
+ ) : trackedDomains.has(item.domain) ? (
) : (
)}
- Bid
+ {item.isPounce ? 'Buy' : 'Bid'}
+ {!item.isPounce && }
),
},
- ], [activeTab, isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain, getOpportunityData])
+ ], [isPaidUser, trackedDomains, trackingInProgress, handleTrackDomain])
+
+ const subtitle = loading
+ ? 'Loading market data...'
+ : `${stats.total} listings • ${stats.direct} direct • ${stats.auctions} auctions`
return (
@@ -477,66 +505,60 @@ export default function AuctionsPage() {
{/* Stats */}
-
-
-
-
+
+ 0}
+ />
+
+
- {/* Tabs */}
- setActiveTab(id as TabType)} />
-
- {/* Smart Filter Presets */}
-
- {FILTER_PRESETS.map((preset) => {
- const isDisabled = preset.proOnly && !isPaidUser
- const isActive = filterPreset === preset.id
- const Icon = preset.icon
- return (
-
!isDisabled && setFilterPreset(preset.id)}
- disabled={isDisabled}
- title={isDisabled ? 'Upgrade to Trader to use this filter' : preset.description}
- className={clsx(
- "flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all",
- isActive
- ? "bg-accent text-background shadow-md"
- : isDisabled
- ? "text-foreground-subtle opacity-50 cursor-not-allowed"
- : "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
- )}
- >
-
- {preset.label}
- {preset.proOnly && !isPaidUser && }
-
- )
- })}
+ {/* Filter Toggles (as per concept) */}
+
+
+ Filters:
+
+
+ {/* Hide Spam Toggle - Default ON */}
+ setHideSpam(!hideSpam)}
+ className={clsx(
+ "flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all",
+ hideSpam
+ ? "bg-accent text-background"
+ : "bg-foreground/10 text-foreground-muted hover:bg-foreground/15"
+ )}
+ >
+
+ Hide Spam
+ {hideSpam && }
+
+
+ {/* Pounce Direct Only Toggle */}
+ setPounceOnly(!pounceOnly)}
+ className={clsx(
+ "flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all",
+ pounceOnly
+ ? "bg-accent text-background"
+ : "bg-foreground/10 text-foreground-muted hover:bg-foreground/15"
+ )}
+ >
+
+ Pounce Direct Only
+ {pounceOnly && }
+
- {/* Tier notification for Scout users */}
- {!isPaidUser && (
-
-
-
-
-
-
You're seeing the raw auction feed
-
- Upgrade to Trader for spam-free listings, Deal Scores, and Smart Filters.
-
-
-
- Upgrade
-
-
- )}
-
- {/* Filters */}
+ {/* Search & Dropdowns */}
-
-
-
- setMaxBid(e.target.value)}
- className="w-28 h-10 pl-9 pr-3 bg-background-secondary/50 border border-border/40 rounded-xl
- text-sm text-foreground placeholder:text-foreground-subtle
- focus:outline-none focus:border-accent/50 transition-all"
- />
-
+
+
- {/* Table */}
+ {/* Upgrade Notice for Scout */}
+ {!isPaidUser && (
+
+
+
+
+
+
Upgrade for Full Market Intelligence
+
+ See Pounce Scores for all domains, unlock advanced filters, and get notified on deals.
+
+
+
+ Upgrade
+
+
+ )}
+
+ {/* Market Table */}
`${a.domain}-${a.platform}`}
+ data={marketItems}
+ keyExtractor={(item) => `${item.domain}-${item.source}`}
loading={loading}
- sortBy={sortBy}
- sortDirection={sortDirection}
- onSort={handleSort}
- emptyIcon={ }
- emptyTitle={searchQuery ? `No auctions matching "${searchQuery}"` : "No auctions found"}
+ emptyIcon={ }
+ emptyTitle={searchQuery ? `No listings matching "${searchQuery}"` : "No listings found"}
emptyDescription="Try adjusting your filters or check back later"
columns={columns}
/>
diff --git a/frontend/src/app/terminal/radar/page.tsx b/frontend/src/app/terminal/radar/page.tsx
index 3e87ce5..be78530 100644
--- a/frontend/src/app/terminal/radar/page.tsx
+++ b/frontend/src/app/terminal/radar/page.tsx
@@ -5,24 +5,28 @@ import { useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { TerminalLayout } from '@/components/TerminalLayout'
-import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader, SearchInput, ActionButton } from '@/components/PremiumTable'
+import { Ticker, useTickerItems } from '@/components/Ticker'
+import { PremiumTable, StatCard, PageContainer, Badge, SectionHeader, ActionButton } from '@/components/PremiumTable'
import { Toast, useToast } from '@/components/Toast'
import {
Eye,
- Briefcase,
- TrendingUp,
Gavel,
+ Tag,
Clock,
ExternalLink,
Sparkles,
- ChevronRight,
Plus,
Zap,
Crown,
Activity,
- Loader2,
- Search,
Bell,
+ Search,
+ TrendingUp,
+ ArrowRight,
+ Globe,
+ CheckCircle2,
+ XCircle,
+ Loader2,
} from 'lucide-react'
import clsx from 'clsx'
import Link from 'next/link'
@@ -42,23 +46,34 @@ interface TrendingTld {
reason: string
}
-export default function DashboardPage() {
+interface SearchResult {
+ available: boolean | null
+ inAuction: boolean
+ inMarketplace: boolean
+ auctionData?: HotAuction
+ loading: boolean
+}
+
+export default function RadarPage() {
const searchParams = useSearchParams()
const {
isAuthenticated,
isLoading,
user,
domains,
- subscription
+ subscription,
+ addDomain,
} = useStore()
const { toast, showToast, hideToast } = useToast()
const [hotAuctions, setHotAuctions] = useState([])
const [trendingTlds, setTrendingTlds] = useState([])
- const [loadingAuctions, setLoadingAuctions] = useState(true)
- const [loadingTlds, setLoadingTlds] = useState(true)
- const [quickDomain, setQuickDomain] = useState('')
- const [addingDomain, setAddingDomain] = useState(false)
+ const [loadingData, setLoadingData] = useState(true)
+
+ // Universal Search State
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchResult, setSearchResult] = useState(null)
+ const [addingToWatchlist, setAddingToWatchlist] = useState(false)
// Check for upgrade success
useEffect(() => {
@@ -66,7 +81,7 @@ export default function DashboardPage() {
showToast('Welcome to your upgraded plan! 🎉', 'success')
window.history.replaceState({}, '', '/terminal/radar')
}
- }, [searchParams])
+ }, [searchParams, showToast])
const loadDashboardData = useCallback(async () => {
try {
@@ -75,41 +90,87 @@ export default function DashboardPage() {
api.getTrendingTlds().catch(() => ({ trending: [] }))
])
setHotAuctions(auctions.slice(0, 5))
- setTrendingTlds(trending.trending?.slice(0, 4) || [])
+ setTrendingTlds(trending.trending?.slice(0, 6) || [])
} catch (error) {
console.error('Failed to load dashboard data:', error)
} finally {
- setLoadingAuctions(false)
- setLoadingTlds(false)
+ setLoadingData(false)
}
}, [])
- // Load dashboard data
useEffect(() => {
if (isAuthenticated) {
loadDashboardData()
}
}, [isAuthenticated, loadDashboardData])
- const handleQuickAdd = useCallback(async (e: React.FormEvent) => {
- e.preventDefault()
- if (!quickDomain.trim()) return
-
- setAddingDomain(true)
+ // Universal Search - simultaneous check
+ const handleSearch = useCallback(async (domain: string) => {
+ if (!domain.trim()) {
+ setSearchResult(null)
+ return
+ }
+
+ const cleanDomain = domain.trim().toLowerCase()
+ setSearchResult({ available: null, inAuction: false, inMarketplace: false, loading: true })
+
try {
- const store = useStore.getState()
- await store.addDomain(quickDomain.trim())
- setQuickDomain('')
- showToast(`Added ${quickDomain.trim()} to watchlist`, 'success')
+ // Parallel checks
+ const [whoisResult, auctionsResult] = await Promise.all([
+ api.checkDomain(cleanDomain, true).catch(() => null),
+ api.getAuctions(cleanDomain).catch(() => ({ auctions: [] })),
+ ])
+
+ const auctionMatch = (auctionsResult as any).auctions?.find(
+ (a: any) => a.domain.toLowerCase() === cleanDomain
+ )
+
+ const isAvailable = whoisResult && 'is_available' in whoisResult
+ ? whoisResult.is_available
+ : null
+
+ setSearchResult({
+ available: isAvailable,
+ inAuction: !!auctionMatch,
+ inMarketplace: false, // TODO: Check marketplace
+ auctionData: auctionMatch,
+ loading: false,
+ })
+ } catch (error) {
+ setSearchResult({ available: null, inAuction: false, inMarketplace: false, loading: false })
+ }
+ }, [])
+
+ const handleAddToWatchlist = useCallback(async () => {
+ if (!searchQuery.trim()) return
+
+ setAddingToWatchlist(true)
+ try {
+ await addDomain(searchQuery.trim())
+ showToast(`Added ${searchQuery.trim()} to watchlist`, 'success')
+ setSearchQuery('')
+ setSearchResult(null)
} catch (err: any) {
showToast(err.message || 'Failed to add domain', 'error')
} finally {
- setAddingDomain(false)
+ setAddingToWatchlist(false)
}
- }, [quickDomain, showToast])
+ }, [searchQuery, addDomain, showToast])
+
+ // Debounced search
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ if (searchQuery.length > 3) {
+ handleSearch(searchQuery)
+ } else {
+ setSearchResult(null)
+ }
+ }, 500)
+ return () => clearTimeout(timer)
+ }, [searchQuery, handleSearch])
// Memoized computed values
- const { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle } = useMemo(() => {
+ const { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle, listingsCount } = useMemo(() => {
const availableDomains = domains?.filter(d => d.is_available) || []
const totalDomains = domains?.length || 0
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
@@ -127,9 +188,15 @@ export default function DashboardPage() {
subtitle = 'Start tracking domains to find opportunities'
}
- return { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle }
+ // TODO: Get actual listings count from API
+ const listingsCount = 0
+
+ return { availableDomains, totalDomains, tierName, TierIcon, greeting, subtitle, listingsCount }
}, [domains, subscription])
+ // Generate ticker items
+ const tickerItems = useTickerItems(trendingTlds, availableDomains, hotAuctions)
+
if (isLoading || !isAuthenticated) {
return (
@@ -145,88 +212,166 @@ export default function DashboardPage() {
>
{toast &&
}
-
- {/* Quick Add */}
-
-
-
-
-
-
-
- Quick Add to Watchlist
-
-
-
+ {/* A. THE TICKER - Market movements */}
+ {tickerItems.length > 0 && (
+
+
+ )}
- {/* Stats Overview */}
-
+
+ {/* B. QUICK STATS - 3 Cards as per concept */}
+
0 ? `${availableDomains.length} alerts` : undefined}
icon={Eye}
- />
-
-
- 0}
/>
-
+
0 ? `${hotAuctions.length}+` : '0'}
+ subtitle="opportunities"
+ icon={Gavel}
+ />
+
+
+
-
- {/* Activity Feed + Market Pulse */}
+ {/* C. UNIVERSAL SEARCH - Hero Element */}
+
+
+
+
+
+
+
+
+
+
Universal Search
+
Check availability, auctions & marketplace simultaneously
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ placeholder="Enter domain to check (e.g., dream.com)"
+ className="w-full h-14 pl-12 pr-4 bg-background/80 backdrop-blur-sm border border-border/50 rounded-xl
+ text-base text-foreground placeholder:text-foreground-subtle
+ focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20"
+ />
+
+
+ {/* Search Results */}
+ {searchResult && (
+
+ {searchResult.loading ? (
+
+
+ Checking...
+
+ ) : (
+
+ {/* Availability */}
+
+
+ {searchResult.available === true ? (
+
+ ) : searchResult.available === false ? (
+
+ ) : (
+
+ )}
+
+ {searchResult.available === true
+ ? 'Available for registration!'
+ : searchResult.available === false
+ ? 'Currently registered'
+ : 'Could not check availability'}
+
+
+ {searchResult.available === true && (
+
+ Register Now
+
+ )}
+
+
+ {/* In Auction */}
+ {searchResult.inAuction && searchResult.auctionData && (
+
+
+
+
+ In auction: ${searchResult.auctionData.current_bid} ({searchResult.auctionData.time_remaining})
+
+
+
+ Bid Now
+
+
+ )}
+
+ {/* Action Buttons */}
+
+
+ {addingToWatchlist ? (
+
+ ) : (
+
+ )}
+ Add to Watchlist
+
+
+
+ )}
+
+ )}
+
+
+
+ {/* D. RECENT ALERTS + MARKET PULSE */}
- {/* Activity Feed */}
+ {/* Recent Alerts / Activity Feed */}
- View all →
+
+ View all
}
/>
@@ -234,7 +379,7 @@ export default function DashboardPage() {
{availableDomains.length > 0 ? (
- {availableDomains.slice(0, 4).map((domain) => (
+ {availableDomains.slice(0, 5).map((domain) => (
-
{domain.name}
+
{domain.name}
Available for registration!
))}
- {availableDomains.length > 4 && (
-
- +{availableDomains.length - 4} more available
-
- )}
) : totalDomains > 0 ? (
All domains are still registered
- We're monitoring {totalDomains} domains for you
+ Monitoring {totalDomains} domains for you
) : (
@@ -276,7 +416,7 @@ export default function DashboardPage() {
No domains tracked yet
- Add a domain above to start monitoring
+ Use Universal Search above to start
)}
@@ -291,14 +431,14 @@ export default function DashboardPage() {
icon={Gavel}
compact
action={
-
- View all →
+
+ View all
}
/>
- {loadingAuctions ? (
+ {loadingData ? (
{[...Array(4)].map((_, i) => (
@@ -316,7 +456,7 @@ export default function DashboardPage() {
hover:bg-foreground/10 transition-colors group"
>
-
{auction.domain}
+
{auction.domain}
{auction.time_remaining}
@@ -325,7 +465,7 @@ export default function DashboardPage() {
${auction.current_bid}
-
current bid
+
bid
@@ -340,63 +480,6 @@ export default function DashboardPage() {
-
- {/* Trending TLDs */}
-
-
-
- View all →
-
- }
- />
-
-
- {loadingTlds ? (
-
- {[...Array(4)].map((_, i) => (
-
- ))}
-
- ) : trendingTlds.length > 0 ? (
-
- {trendingTlds.map((tld) => (
-
-
-
-
- .{tld.tld}
- 0
- ? "text-orange-400 bg-orange-400/10 border-orange-400/20"
- : "text-accent bg-accent/10 border-accent/20"
- )}>
- {(tld.price_change || 0) > 0 ? '+' : ''}{(tld.price_change || 0).toFixed(1)}%
-
-
-
{tld.reason}
-
-
- ))}
-
- ) : (
-
-
-
No trending TLDs available
-
- )}
-
-
)
diff --git a/frontend/src/app/terminal/watchlist/page.tsx b/frontend/src/app/terminal/watchlist/page.tsx
index ba4fba8..9283ccf 100755
--- a/frontend/src/app/terminal/watchlist/page.tsx
+++ b/frontend/src/app/terminal/watchlist/page.tsx
@@ -37,52 +37,59 @@ import {
import clsx from 'clsx'
import Link from 'next/link'
-// Health status badge colors and icons
+// Health status badge colors and icons (Ampel-System as per concept)
+// 🟢 Online, 🟡 DNS Changed, 🔴 Offline/Error
const healthStatusConfig: Record
= {
healthy: {
- label: 'Healthy',
+ label: 'Online',
color: 'text-accent',
bgColor: 'bg-accent/10 border-accent/20',
icon: Activity,
- description: 'Domain is active and well-maintained'
+ description: 'Domain is active and well-maintained',
+ ampel: '🟢'
},
weakening: {
- label: 'Weakening',
+ label: 'DNS Changed',
color: 'text-amber-400',
bgColor: 'bg-amber-400/10 border-amber-400/20',
icon: AlertTriangle,
- description: 'Warning signs detected - owner may be losing interest'
+ description: 'Warning signs detected - DNS or config changed',
+ ampel: '🟡'
},
parked: {
label: 'For Sale',
color: 'text-orange-400',
bgColor: 'bg-orange-400/10 border-orange-400/20',
icon: ShoppingCart,
- description: 'Domain is parked and likely for sale'
+ description: 'Domain is parked and likely for sale',
+ ampel: '🟡'
},
critical: {
- label: 'Critical',
+ label: 'Offline',
color: 'text-red-400',
bgColor: 'bg-red-400/10 border-red-400/20',
icon: AlertTriangle,
- description: 'Domain drop is imminent!'
+ description: 'Domain is offline or has critical errors',
+ ampel: '🔴'
},
unknown: {
label: 'Unknown',
color: 'text-foreground-muted',
bgColor: 'bg-foreground/5 border-border/30',
icon: HelpCircle,
- description: 'Could not determine status'
+ description: 'Could not determine status',
+ ampel: '⚪'
},
}
-type FilterStatus = 'all' | 'available' | 'watching'
+type FilterStatus = 'watching' | 'portfolio' | 'available'
export default function WatchlistPage() {
const { domains, addDomain, deleteDomain, refreshDomain, subscription } = useStore()
@@ -93,7 +100,7 @@ export default function WatchlistPage() {
const [refreshingId, setRefreshingId] = useState(null)
const [deletingId, setDeletingId] = useState(null)
const [togglingNotifyId, setTogglingNotifyId] = useState(null)
- const [filterStatus, setFilterStatus] = useState('all')
+ const [filterStatus, setFilterStatus] = useState('watching')
const [searchQuery, setSearchQuery] = useState('')
// Health check state
@@ -120,16 +127,17 @@ export default function WatchlistPage() {
return false
}
if (filterStatus === 'available' && !domain.is_available) return false
- if (filterStatus === 'watching' && domain.is_available) return false
+ if (filterStatus === 'portfolio') return false // TODO: filter for verified own domains
+ // 'watching' shows all domains
return true
})
}, [domains, searchQuery, filterStatus])
- // Memoized tabs config
+ // Memoized tabs config - as per concept: Watching + My Portfolio
const tabs = useMemo(() => [
- { id: 'all', label: 'All', count: stats.domainsUsed },
- { id: 'available', label: 'Available', count: stats.availableCount, color: 'accent' as const },
- { id: 'watching', label: 'Monitoring', count: stats.watchingCount },
+ { id: 'watching', label: 'Watching', icon: Eye, count: stats.domainsUsed },
+ { id: 'portfolio', label: 'My Portfolio', icon: Shield, count: 0 }, // TODO: verified own domains
+ { id: 'available', label: 'Available', icon: Sparkles, count: stats.availableCount, color: 'accent' as const },
], [stats])
// Callbacks - prevent recreation on every render
@@ -234,18 +242,18 @@ export default function WatchlistPage() {
),
},
{
- key: 'status',
- header: 'Status',
- align: 'left' as const,
+ key: 'health',
+ header: 'Health',
+ align: 'center' as const,
+ width: '100px',
hideOnMobile: true,
render: (domain: any) => {
const health = healthReports[domain.id]
if (health) {
const config = healthStatusConfig[health.status]
- const Icon = config.icon
return (
-
-
+
+ {config.ampel}
{config.label}
)
@@ -255,7 +263,7 @@ export default function WatchlistPage() {
"text-sm",
domain.is_available ? "text-accent font-medium" : "text-foreground-muted"
)}>
- {domain.is_available ? 'Ready to pounce!' : 'Monitoring...'}
+ {domain.is_available ? '🟢 Available' : '⚪ Checking...'}
)
},
diff --git a/frontend/src/components/Ticker.tsx b/frontend/src/components/Ticker.tsx
new file mode 100644
index 0000000..e74e47f
--- /dev/null
+++ b/frontend/src/components/Ticker.tsx
@@ -0,0 +1,165 @@
+'use client'
+
+import { useEffect, useState, useRef } from 'react'
+import { TrendingUp, TrendingDown, AlertCircle, Sparkles, Gavel } from 'lucide-react'
+import clsx from 'clsx'
+
+export interface TickerItem {
+ id: string
+ type: 'tld_change' | 'domain_available' | 'auction_ending' | 'alert'
+ message: string
+ value?: string
+ change?: number
+ urgent?: boolean
+}
+
+interface TickerProps {
+ items: TickerItem[]
+ speed?: number // pixels per second
+}
+
+export function Ticker({ items, speed = 50 }: TickerProps) {
+ const containerRef = useRef
(null)
+ const contentRef = useRef(null)
+ const [animationDuration, setAnimationDuration] = useState(0)
+
+ useEffect(() => {
+ if (contentRef.current && containerRef.current) {
+ const contentWidth = contentRef.current.scrollWidth
+ const duration = contentWidth / speed
+ setAnimationDuration(duration)
+ }
+ }, [items, speed])
+
+ if (items.length === 0) return null
+
+ const getIcon = (type: TickerItem['type'], change?: number) => {
+ switch (type) {
+ case 'tld_change':
+ return change && change > 0
+ ?
+ :
+ case 'domain_available':
+ return
+ case 'auction_ending':
+ return
+ case 'alert':
+ return
+ default:
+ return null
+ }
+ }
+
+ const getValueColor = (type: TickerItem['type'], change?: number) => {
+ if (type === 'tld_change') {
+ return change && change > 0 ? 'text-orange-400' : 'text-accent'
+ }
+ return 'text-accent'
+ }
+
+ // Duplicate items for seamless loop
+ const tickerItems = [...items, ...items]
+
+ return (
+
+ {/* Fade edges */}
+
+
+
+
+ {tickerItems.map((item, idx) => (
+
+ {getIcon(item.type, item.change)}
+ {item.message}
+ {item.value && (
+
+ {item.value}
+
+ )}
+ {item.change !== undefined && (
+ 0 ? "text-orange-400" : "text-accent"
+ )}>
+ {item.change > 0 ? '+' : ''}{item.change.toFixed(1)}%
+
+ )}
+
+ ))}
+
+
+
+
+ )
+}
+
+// Hook to generate ticker items from various data sources
+export function useTickerItems(
+ trendingTlds: Array<{ tld: string; price_change: number; current_price: number }>,
+ availableDomains: Array<{ name: string }>,
+ hotAuctions: Array<{ domain: string; time_remaining: string }>
+): TickerItem[] {
+ const items: TickerItem[] = []
+
+ // Add TLD changes
+ trendingTlds.forEach((tld) => {
+ items.push({
+ id: `tld-${tld.tld}`,
+ type: 'tld_change',
+ message: `.${tld.tld}`,
+ value: `$${tld.current_price.toFixed(2)}`,
+ change: tld.price_change,
+ })
+ })
+
+ // Add available domains
+ availableDomains.slice(0, 3).forEach((domain) => {
+ items.push({
+ id: `available-${domain.name}`,
+ type: 'domain_available',
+ message: `${domain.name} is available!`,
+ urgent: true,
+ })
+ })
+
+ // Add ending auctions
+ hotAuctions.slice(0, 3).forEach((auction) => {
+ items.push({
+ id: `auction-${auction.domain}`,
+ type: 'auction_ending',
+ message: `${auction.domain}`,
+ value: auction.time_remaining,
+ })
+ })
+
+ return items
+}
+
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 414247f..246217f 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -323,6 +323,23 @@ class ApiClient {
})
}
+ // Marketplace Listings (Pounce Direct)
+ async getMarketplaceListings() {
+ // TODO: Implement backend endpoint for marketplace listings
+ // For now, return empty array
+ return Promise.resolve({
+ listings: [] as Array<{
+ id: number
+ domain: string
+ price: number
+ is_negotiable: boolean
+ verified: boolean
+ seller_name: string
+ created_at: string
+ }>
+ })
+ }
+
// Subscription
async getSubscription() {
return this.request<{