From fffbc4747a1ad670fd623453e69c1d52ec85dac0 Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Fri, 12 Dec 2025 10:29:47 +0100 Subject: [PATCH] feat: Portfolio Sell Wizard & Tier-based Limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3-step sell wizard modal in Portfolio (Details → DNS Verify → Done) - Implement DNS TXT verification for domain ownership - Remove Marketplace button from For Sale page - Show clear tier-based limits everywhere: - Watchlist: Scout=5, Trader=50, Tycoon=500 - Listings: Scout=0, Trader=5, Tycoon=50 - Add plan comparison in For Sale upgrade section - Prevent selling if listing limit reached - Add copy-to-clipboard for DNS records --- frontend/src/app/auctions/page.tsx | 726 +---------- frontend/src/app/intel/[tld]/page.tsx | 1187 +++++++++++++++++ frontend/src/app/intel/page.tsx | 547 ++++++++ frontend/src/app/market/page.tsx | 737 ++++++++++- frontend/src/app/page.tsx | 82 +- frontend/src/app/pricing/page.tsx | 56 +- frontend/src/app/terminal/listing/page.tsx | 56 +- frontend/src/app/terminal/watchlist/page.tsx | 512 +++++++- frontend/src/app/tld-pricing/[tld]/page.tsx | 1190 +----------------- frontend/src/app/tld-pricing/page.tsx | 581 +-------- frontend/src/components/Header.tsx | 19 +- 11 files changed, 3147 insertions(+), 2546 deletions(-) create mode 100644 frontend/src/app/intel/[tld]/page.tsx create mode 100644 frontend/src/app/intel/page.tsx diff --git a/frontend/src/app/auctions/page.tsx b/frontend/src/app/auctions/page.tsx index b049269..ac76358 100644 --- a/frontend/src/app/auctions/page.tsx +++ b/frontend/src/app/auctions/page.tsx @@ -1,721 +1,25 @@ 'use client' -import { useEffect, useState, useMemo } from 'react' -import { useStore } from '@/lib/store' -import { api } from '@/lib/api' -import { Header } from '@/components/Header' -import { Footer } from '@/components/Footer' -import { PlatformBadge } from '@/components/PremiumTable' -import { - Clock, - ExternalLink, - Search, - Flame, - Timer, - Gavel, - DollarSign, - X, - Lock, - TrendingUp, - ChevronUp, - ChevronDown, - ChevronsUpDown, - Sparkles, - Diamond, - ShieldCheck, - Zap, -} from 'lucide-react' -import Link from 'next/link' -import clsx from 'clsx' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' -interface MarketItem { - id: string - domain: string - tld: string - price: number - currency: string - price_type: 'bid' | 'fixed' | 'negotiable' - status: 'auction' | 'instant' - source: string - is_pounce: boolean - verified: boolean - time_remaining?: string - end_time?: string - num_bids?: number - slug?: string - seller_verified: boolean - url: string - is_external: boolean - pounce_score: number -} - -interface Auction { - domain: string - platform: string - platform_url: string - current_bid: number - currency: string - num_bids: number - end_time: string - time_remaining: string - buy_now_price: number | null - reserve_met: boolean | null - traffic: number | null - age_years: number | null - tld: string - affiliate_url: string -} - -type TabType = 'all' | 'ending' | 'hot' -type SortField = 'domain' | 'ending' | 'bid' | 'bids' -type SortDirection = 'asc' | 'desc' - -const PLATFORMS = [ - { id: 'All', name: 'All Sources' }, - { id: 'GoDaddy', name: 'GoDaddy' }, - { id: 'Sedo', name: 'Sedo' }, - { id: 'NameJet', name: 'NameJet' }, - { id: 'DropCatch', name: 'DropCatch' }, -] - -// Premium TLDs that look professional (from analysis_1.md) -const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz'] - -// Vanity Filter: Only show "beautiful" domains to non-authenticated users (from analysis_1.md) -// Rules: No numbers (except short domains), no hyphens, length < 12, only premium TLDs -function isVanityDomain(auction: Auction): boolean { - const domain = auction.domain - const parts = domain.split('.') - if (parts.length < 2) return false +/** + * Redirect /auctions to /market + * This page is kept for backwards compatibility + */ +export default function AuctionsRedirect() { + const router = useRouter() - const name = parts[0] - const tld = parts.slice(1).join('.').toLowerCase() - - // Check TLD is premium - if (!PREMIUM_TLDS.includes(tld)) return false - - // Check length (max 12 characters for the name) - if (name.length > 12) return false - - // No hyphens - if (name.includes('-')) return false - - // No numbers (unless domain is 4 chars or less - short domains are valuable) - if (name.length > 4 && /\d/.test(name)) return false - - return true -} - -// Generate a mock "Deal Score" for display purposes -// In production, this would come from a valuation API -function getDealScore(auction: Auction): number | null { - // Simple heuristic based on domain characteristics - let score = 50 - - // Short domains are more valuable - const name = auction.domain.split('.')[0] - if (name.length <= 4) score += 20 - else if (name.length <= 6) score += 10 - - // Premium TLDs - if (['com', 'io', 'ai'].includes(auction.tld)) score += 15 - - // Age bonus - if (auction.age_years && auction.age_years > 5) score += 10 - - // High competition = good domain - if (auction.num_bids >= 20) score += 15 - else if (auction.num_bids >= 10) score += 10 - - // Cap at 100 - return Math.min(score, 100) -} - -function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) { - if (field !== currentField) { - return - } - return direction === 'asc' - ? - : -} - -export default function AuctionsPage() { - const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() - - const [allAuctions, setAllAuctions] = useState([]) - const [endingSoon, setEndingSoon] = useState([]) - const [hotAuctions, setHotAuctions] = useState([]) - const [pounceItems, setPounceItems] = useState([]) - const [loading, setLoading] = useState(true) - const [activeTab, setActiveTab] = useState('all') - const [sortField, setSortField] = useState('ending') - const [sortDirection, setSortDirection] = useState('asc') - - const [searchQuery, setSearchQuery] = useState('') - const [selectedPlatform, setSelectedPlatform] = useState('All') - const [maxBid, setMaxBid] = useState('') - useEffect(() => { - checkAuth() - loadAuctions() - }, [checkAuth]) - - const loadAuctions = async () => { - setLoading(true) - try { - // Use unified feed API for all data - same as Terminal Market Page - const [allFeed, endingFeed, hotFeed, pounceFeed] = await Promise.all([ - api.getMarketFeed({ source: 'all', limit: 100, sortBy: 'time' }), - api.getMarketFeed({ source: 'external', endingWithin: 24, limit: 50, sortBy: 'time' }), - api.getMarketFeed({ source: 'external', limit: 50, sortBy: 'score' }), // Hot = highest score - api.getMarketFeed({ source: 'pounce', limit: 10 }), - ]) - - // Convert MarketItem to Auction format for compatibility - const convertToAuction = (item: MarketItem): Auction => ({ - domain: item.domain, - platform: item.source, - platform_url: item.url, - current_bid: item.price, - currency: item.currency, - num_bids: item.num_bids || 0, - end_time: item.end_time || '', - time_remaining: item.time_remaining || '', - buy_now_price: item.price_type === 'fixed' ? item.price : null, - reserve_met: null, - traffic: null, - age_years: null, - tld: item.tld, - affiliate_url: item.url, - }) - - // Filter out Pounce Direct from auction lists (they go in separate section) - const externalOnly = (items: MarketItem[]) => items.filter(i => !i.is_pounce).map(convertToAuction) - - setAllAuctions(externalOnly(allFeed.items || [])) - setEndingSoon(externalOnly(endingFeed.items || [])) - setHotAuctions(externalOnly(hotFeed.items || [])) - setPounceItems(pounceFeed.items || []) - } catch (error) { - console.error('Failed to load auctions:', error) - } finally { - setLoading(false) - } - } - - const getCurrentAuctions = (): Auction[] => { - switch (activeTab) { - case 'ending': return endingSoon - case 'hot': return hotAuctions - default: return allAuctions - } - } - - // Apply Vanity Filter for non-authenticated users (from analysis_1.md) - // Shows only "beautiful" domains to visitors - no spam/trash - const displayAuctions = useMemo(() => { - const current = getCurrentAuctions() - if (isAuthenticated) { - // Authenticated users see all auctions - return current - } - // Non-authenticated users only see "vanity" domains (clean, professional-looking) - return current.filter(isVanityDomain) - }, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated]) - - const filteredAuctions = displayAuctions.filter(auction => { - if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) { - return false - } - if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) { - return false - } - if (maxBid && auction.current_bid > parseFloat(maxBid)) { - return false - } - return true - }) - - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') - } else { - setSortField(field) - setSortDirection('asc') - } - } - - const sortedAuctions = [...filteredAuctions].sort((a, b) => { - const modifier = sortDirection === 'asc' ? 1 : -1 - switch (sortField) { - case 'domain': - return a.domain.localeCompare(b.domain) * modifier - case 'bid': - return (a.current_bid - b.current_bid) * modifier - case 'bids': - return (a.num_bids - b.num_bids) * modifier - default: - return 0 - } - }) - - const formatCurrency = (amount: number, currency = 'USD') => { - return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount) - } - - const getTimeColor = (timeRemaining: string) => { - if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400' - if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400' - return 'text-foreground-muted' - } - - // Hot auctions preview for the hero section - const hotPreview = hotAuctions.slice(0, 4) - - if (authLoading) { - return ( -
-
-
- ) - } - + router.replace('/market') + }, [router]) + return ( -
- {/* Background Effects - matching landing page */} -
-
-
-
+
+
+
+

Redirecting to Market...

- -
- -
-
- {/* Hero Header - centered like TLD pricing */} -
- Live Market -

- {/* Use "Live Feed" or "Curated Opportunities" if count is small (from report.md) */} - {allAuctions.length >= 50 - ? `${allAuctions.length}+ Live Auctions` - : 'Live Auction Feed'} -

-

- {isAuthenticated - ? 'All auctions from GoDaddy, Sedo, NameJet & DropCatch. Unfiltered.' - : 'Curated opportunities from GoDaddy, Sedo, NameJet & DropCatch.'} -

- {!isAuthenticated && displayAuctions.length < allAuctions.length && ( -

- - Showing {displayAuctions.length} premium domains • Sign in to see all {allAuctions.length} -

- )} -
- - {/* Login Banner for non-authenticated users */} - {!isAuthenticated && ( -
-
-
- -
-
-

Unlock Smart Opportunities

-

- Sign in for AI-powered analysis and personalized recommendations. -

-
-
- - Hunt Free - -
- )} - - {/* Pounce Direct Section - Featured */} - {pounceItems.length > 0 && ( -
-
-
- -

- Pounce Exclusive -

-
- Verified • Instant Buy • 0% Commission -
-
- {pounceItems.map((item) => ( - -
- -
-
- {item.domain} -
-
- {item.verified && ( - - - Verified - - )} - - Score: {item.pounce_score} - -
-
-
-
-
-
- {formatCurrency(item.price, item.currency)} -
-
Instant Buy
-
-
- Buy Now - -
-
- - ))} -
-
- - Browse all Pounce listings → - -
-
- )} - - {/* Hot Auctions Preview */} - {hotPreview.length > 0 && ( - - )} - - {/* Search & Filters */} -
-
-
- - setSearchQuery(e.target.value)} - className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl - text-body text-foreground placeholder:text-foreground-subtle - focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent - transition-all duration-300" - /> - {searchQuery && ( - - )} -
- -
- - setMaxBid(e.target.value)} - className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl - text-body text-foreground placeholder:text-foreground-subtle - focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent" - /> -
-
-
- - {/* Tabs */} -
- {[ - { id: 'all' as const, label: 'All Auctions', icon: Gavel, count: allAuctions.length }, - { id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length }, - { id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length }, - ].map((tab) => ( - - ))} -
- - {/* Auctions Table */} -
-
- - - - - - - - - - - - - - {loading ? ( - // Loading skeleton - Array.from({ length: 10 }).map((_, idx) => ( - - - - - - - - - - )) - ) : sortedAuctions.length === 0 ? ( - - - - ) : ( - sortedAuctions.map((auction) => ( - - - - - {/* Deal Score Column - locked for non-authenticated users */} - - - - - - )) - )} - -
- - - Platform - - - - - Deal Score - {!isAuthenticated && } - - - - - -
- {searchQuery ? `No auctions found matching "${searchQuery}"` : 'No auctions found'} -
- - -
- - {auction.age_years && ( - - {auction.age_years}y - - )} -
-
-
- - {formatCurrency(auction.current_bid)} - - {auction.buy_now_price && ( -

Buy: {formatCurrency(auction.buy_now_price)}

- )} -
-
- {isAuthenticated ? ( -
- = 75 ? "bg-accent/20 text-accent" : - (getDealScore(auction) ?? 0) >= 50 ? "bg-amber-500/20 text-amber-400" : - "bg-foreground/10 text-foreground-muted" - )}> - {getDealScore(auction)} - - {(getDealScore(auction) ?? 0) >= 75 && ( - Undervalued - )} -
- ) : ( - - - - )} -
- = 20 ? "text-accent" : auction.num_bids >= 10 ? "text-amber-400" : "text-foreground-muted" - )}> - {auction.num_bids} - {auction.num_bids >= 20 && } - - - - {auction.time_remaining} - - - - Bid - - -
-
-
- - {/* Stats */} - {!loading && ( -
-

- {searchQuery - ? `Found ${sortedAuctions.length} auctions matching "${searchQuery}"` - : `${allAuctions.length} auctions available across ${PLATFORMS.length - 1} platforms` - } -

-
- )} -
-
- -
) } diff --git a/frontend/src/app/intel/[tld]/page.tsx b/frontend/src/app/intel/[tld]/page.tsx new file mode 100644 index 0000000..86e94a1 --- /dev/null +++ b/frontend/src/app/intel/[tld]/page.tsx @@ -0,0 +1,1187 @@ +'use client' + +import { useEffect, useState, useMemo, useRef } from 'react' +import { useParams } from 'next/navigation' +import { Header } from '@/components/Header' +import { Footer } from '@/components/Footer' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { + ArrowLeft, + TrendingUp, + TrendingDown, + Minus, + Calendar, + Globe, + Building, + ExternalLink, + Search, + ChevronRight, + Check, + X, + Lock, + RefreshCw, + Clock, + Shield, + Zap, + AlertTriangle, +} from 'lucide-react' +import Link from 'next/link' +import clsx from 'clsx' + +interface TldDetails { + tld: string + type: string + description: string + registry: string + introduced: number + trend: string + trend_reason: string + pricing: { + avg: number + min: number + max: number + } + registrars: Array<{ + name: string + registration_price: number + renewal_price: number + transfer_price: number + }> + cheapest_registrar: string + // New fields from table + min_renewal_price: number + price_change_1y: number + price_change_3y: number + risk_level: 'low' | 'medium' | 'high' + risk_reason: string +} + +interface TldHistory { + tld: string + type?: string + description?: string + registry?: string + current_price: number + price_change_7d: number + price_change_30d: number + price_change_90d: number + trend: string + trend_reason: string + history: Array<{ + date: string + price: number + }> + source?: string +} + +interface DomainCheckResult { + domain: string + is_available: boolean + status: string + registrar?: string | null + creation_date?: string | null + expiration_date?: string | null +} + +// Registrar URLs +const REGISTRAR_URLS: Record = { + 'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=', + 'Porkbun': 'https://porkbun.com/checkout/search?q=', + 'Cloudflare': 'https://www.cloudflare.com/products/registrar/', + 'Google Domains': 'https://domains.google.com/registrar/search?searchTerm=', + 'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=', + 'porkbun': 'https://porkbun.com/checkout/search?q=', + 'Dynadot': 'https://www.dynadot.com/domain/search?domain=', + 'Hover': 'https://www.hover.com/domains/results?q=', +} + +// Related TLDs +const RELATED_TLDS: Record = { + 'com': ['net', 'org', 'co', 'io'], + 'net': ['com', 'org', 'io', 'dev'], + 'org': ['com', 'net', 'ngo', 'foundation'], + 'io': ['dev', 'app', 'tech', 'ai'], + 'ai': ['io', 'tech', 'dev', 'ml'], + 'dev': ['io', 'app', 'tech', 'code'], + 'app': ['dev', 'io', 'mobile', 'software'], + 'co': ['com', 'io', 'biz', 'inc'], + 'de': ['at', 'ch', 'eu', 'com'], + 'ch': ['de', 'at', 'li', 'eu'], +} + +type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL' + +// Shimmer component for unauthenticated users +function Shimmer({ className }: { className?: string }) { + return ( +
+
+
+ ) +} + +// Premium Chart Component with real data +function PriceChart({ + data, + isAuthenticated, + chartStats, +}: { + data: Array<{ date: string; price: number }> + isAuthenticated: boolean + chartStats: { high: number; low: number; avg: number } +}) { + const [hoveredIndex, setHoveredIndex] = useState(null) + const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }) + const containerRef = useRef(null) + + if (!isAuthenticated) { + return ( +
+
+ +
+ + Sign in to view price history +
+
+ ) + } + + if (data.length === 0) { + return ( +
+ No price history available +
+ ) + } + + const range = chartStats.high - chartStats.low || 1 + const padding = range * 0.1 + + // Create smooth path + const points = data.map((point, i) => { + const x = (i / (data.length - 1)) * 100 + const y = 100 - ((point.price - chartStats.low + padding) / (range + padding * 2)) * 100 + return { x, y, ...point } + }) + + // Create SVG path for smooth curve + const linePath = points.reduce((path, point, i) => { + if (i === 0) return `M ${point.x} ${point.y}` + + // Use bezier curves for smoothness + const prev = points[i - 1] + const cpx = (prev.x + point.x) / 2 + return `${path} C ${cpx} ${prev.y}, ${cpx} ${point.y}, ${point.x} ${point.y}` + }, '') + + // Area path + const areaPath = `${linePath} L 100 100 L 0 100 Z` + + const handleMouseMove = (e: React.MouseEvent) => { + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const percentage = x / rect.width + const index = Math.round(percentage * (data.length - 1)) + if (index >= 0 && index < data.length) { + setHoveredIndex(index) + setTooltipPos({ x: e.clientX - rect.left, y: points[index].y * rect.height / 100 }) + } + } + + return ( +
+ setHoveredIndex(null)} + > + + + + + + + + + + + + + + + {/* Grid lines */} + {[20, 40, 60, 80].map(y => ( + + ))} + + {/* Area fill */} + + + {/* Main line */} + + + {/* Hover indicator */} + {hoveredIndex !== null && ( + + + + )} + + + {/* Hover dot */} + {hoveredIndex !== null && containerRef.current && ( +
+ )} + + {/* Tooltip */} + {hoveredIndex !== null && ( +
+

+ ${data[hoveredIndex].price.toFixed(2)} +

+

+ {new Date(data[hoveredIndex].date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + })} +

+
+ )} +
+ ) +} + +// Domain Check Result Card +function DomainResultCard({ + result, + tld, + cheapestPrice, + cheapestRegistrar, + onClose +}: { + result: DomainCheckResult + tld: string + cheapestPrice: number + cheapestRegistrar: string + onClose: () => void +}) { + const registrarUrl = REGISTRAR_URLS[cheapestRegistrar] || '#' + + return ( +
+
+
+
+
+ {result.is_available ? ( + + ) : ( + + )} +
+
+

{result.domain}

+

+ {result.is_available ? 'Available for registration' : 'Already registered'} +

+
+
+ + {result.is_available ? ( +
+
+ + + Register from ${cheapestPrice.toFixed(2)}/yr + +
+ + Register at {cheapestRegistrar} + + +
+ ) : ( +
+ {result.registrar && ( +
+ + Registrar: {result.registrar} +
+ )} + {result.expiration_date && ( +
+ + Expires: {new Date(result.expiration_date).toLocaleDateString()} +
+ )} +
+ )} +
+ + +
+
+ ) +} + +export default function TldDetailPage() { + const params = useParams() + const { isAuthenticated, checkAuth, isLoading: authLoading, subscription, fetchSubscription } = useStore() + const tld = params.tld as string + + // Feature flags based on subscription + const hasPriceHistory = (subscription?.history_days ?? 0) !== 0 + + const [details, setDetails] = useState(null) + const [history, setHistory] = useState(null) + const [relatedTlds, setRelatedTlds] = useState>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [chartPeriod, setChartPeriod] = useState('1Y') + const [domainSearch, setDomainSearch] = useState('') + const [checkingDomain, setCheckingDomain] = useState(false) + const [domainResult, setDomainResult] = useState(null) + + useEffect(() => { + checkAuth() + fetchSubscription() + }, [checkAuth, fetchSubscription]) + + useEffect(() => { + if (tld) { + loadData() + loadRelatedTlds() + } + }, [tld]) + + const loadData = async () => { + try { + const [historyData, compareData, overviewData] = await Promise.all([ + api.getTldHistory(tld, 365), + api.getTldCompare(tld), + api.getTldOverview(1, 0, 'popularity', tld), + ]) + + if (historyData && compareData) { + const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) => + a.registration_price - b.registration_price + ) + + // Get additional data from overview API + const tldFromOverview = overviewData?.tlds?.[0] + + setDetails({ + tld: compareData.tld || tld, + type: compareData.type || 'generic', + description: compareData.description || `Domain extension .${tld}`, + registry: compareData.registry || 'Various', + introduced: compareData.introduced || 0, + trend: historyData.trend || 'stable', + trend_reason: historyData.trend_reason || 'Price tracking available', + pricing: { + avg: compareData.price_range?.avg || historyData.current_price || 0, + min: compareData.price_range?.min || historyData.current_price || 0, + max: compareData.price_range?.max || historyData.current_price || 0, + }, + registrars: sortedRegistrars, + cheapest_registrar: compareData.cheapest_registrar || sortedRegistrars[0]?.name || 'N/A', + // New fields from overview + min_renewal_price: tldFromOverview?.min_renewal_price || sortedRegistrars[0]?.renewal_price || 0, + price_change_1y: tldFromOverview?.price_change_1y || 0, + price_change_3y: tldFromOverview?.price_change_3y || 0, + risk_level: tldFromOverview?.risk_level || 'low', + risk_reason: tldFromOverview?.risk_reason || 'Stable', + }) + setHistory(historyData) + } else { + setError('Failed to load TLD data') + } + } catch (err) { + console.error('Error loading TLD data:', err) + setError('Failed to load TLD data') + } finally { + setLoading(false) + } + } + + const loadRelatedTlds = async () => { + const related = RELATED_TLDS[tld.toLowerCase()] || ['com', 'net', 'org', 'io'] + const relatedData: Array<{ tld: string; price: number }> = [] + + for (const relatedTld of related.slice(0, 4)) { + try { + const data = await api.getTldHistory(relatedTld, 30) + if (data) { + relatedData.push({ tld: relatedTld, price: data.current_price }) + } + } catch { + // Skip failed + } + } + setRelatedTlds(relatedData) + } + + const filteredHistory = useMemo(() => { + if (!history?.history) return [] + + const now = new Date() + let cutoffDays = 365 + + switch (chartPeriod) { + case '1M': cutoffDays = 30; break + case '3M': cutoffDays = 90; break + case '1Y': cutoffDays = 365; break + case 'ALL': cutoffDays = 9999; break + } + + const cutoff = new Date(now.getTime() - cutoffDays * 24 * 60 * 60 * 1000) + return history.history.filter(h => new Date(h.date) >= cutoff) + }, [history, chartPeriod]) + + const chartStats = useMemo(() => { + if (filteredHistory.length === 0) return { high: 0, low: 0, avg: 0 } + const prices = filteredHistory.map(h => h.price) + return { + high: Math.max(...prices), + low: Math.min(...prices), + avg: prices.reduce((a, b) => a + b, 0) / prices.length, + } + }, [filteredHistory]) + + const handleDomainCheck = async () => { + if (!domainSearch.trim()) return + + setCheckingDomain(true) + setDomainResult(null) + + try { + const domain = domainSearch.includes('.') ? domainSearch : `${domainSearch}.${tld}` + const result = await api.checkDomain(domain, false) + setDomainResult({ + domain, + is_available: result.is_available, + status: result.status, + registrar: result.registrar, + creation_date: result.creation_date, + expiration_date: result.expiration_date, + }) + } catch (err) { + console.error('Domain check failed:', err) + } finally { + setCheckingDomain(false) + } + } + + const getRegistrarUrl = (registrarName: string, domain?: string) => { + const baseUrl = REGISTRAR_URLS[registrarName] + if (!baseUrl) return '#' + if (domain) return `${baseUrl}${domain}` + return baseUrl + } + + const savings = useMemo(() => { + if (!details || details.registrars.length < 2) return null + const cheapest = details.registrars[0].registration_price + const mostExpensive = details.registrars[details.registrars.length - 1].registration_price + return { + amount: mostExpensive - cheapest, + cheapestName: details.registrars[0].name, + expensiveName: details.registrars[details.registrars.length - 1].name, + } + }, [details]) + + // Renewal trap info + const renewalInfo = useMemo(() => { + if (!details?.registrars?.length) return null + const cheapest = details.registrars[0] + const ratio = cheapest.renewal_price / cheapest.registration_price + return { + registration: cheapest.registration_price, + renewal: cheapest.renewal_price, + ratio, + isTrap: ratio > 2, + } + }, [details]) + + // Risk badge component + const getRiskBadge = () => { + if (!details) return null + const level = details.risk_level + const reason = details.risk_reason + return ( + + + {reason} + + ) + } + + const getTrendIcon = (trend: string) => { + switch (trend) { + case 'up': return + case 'down': return + default: return + } + } + + if (loading || authLoading) { + return ( +
+
+
+
+ + +
+ {[1, 2, 3, 4].map(i => )} +
+ +
+
+
+ ) + } + + if (error || !details) { + return ( +
+
+
+
+
+ +
+

TLD Not Found

+

{error || `The TLD .${tld} could not be found.`}

+ + + Back to Intel + +
+
+
+
+ ) + } + + return ( +
+ {/* Subtle ambient */} +
+
+
+ +
+ +
+
+ + {/* Breadcrumb */} + + + {/* Hero */} +
+ {/* Left: TLD Info */} +
+
+

+ .{details.tld} +

+
+ {getTrendIcon(details.trend)} + {details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'} +
+
+ +

{details.description}

+

{details.trend_reason}

+ + {/* Quick Stats - All data from table */} +
+
+

Buy (1y)

+ {isAuthenticated ? ( +

${details.pricing.min.toFixed(2)}

+ ) : ( + + )} +
+
+

Renew (1y)

+ {isAuthenticated ? ( +
+

+ ${details.min_renewal_price.toFixed(2)} +

+ {renewalInfo?.isTrap && ( + + + + )} +
+ ) : ( + + )} +
+
+

1y Change

+ {isAuthenticated ? ( +

0 ? "text-orange-400" : + details.price_change_1y < 0 ? "text-accent" : + "text-foreground" + )}> + {details.price_change_1y > 0 ? '+' : ''}{details.price_change_1y.toFixed(0)}% +

+ ) : ( + + )} +
+
+

3y Change

+ {isAuthenticated ? ( +

0 ? "text-orange-400" : + details.price_change_3y < 0 ? "text-accent" : + "text-foreground" + )}> + {details.price_change_3y > 0 ? '+' : ''}{details.price_change_3y.toFixed(0)}% +

+ ) : ( + + )} +
+
+ + {/* Risk Assessment */} + {isAuthenticated && ( +
+ +
+

Risk Assessment

+
+ {getRiskBadge()} +
+ )} +
+ + {/* Right: Price Card */} +
+
+ {isAuthenticated ? ( + <> +
+ + ${details.pricing.min.toFixed(2)} + + /yr +
+

+ Cheapest at {details.cheapest_registrar} +

+ + + + {savings && savings.amount > 0.5 && ( +
+
+ +

+ Save ${savings.amount.toFixed(2)}/yr vs {savings.expensiveName} +

+
+
+ )} + + ) : ( + <> + + + + + Sign in to View Prices + + + )} +
+
+
+ + {/* Renewal Trap Warning */} + {isAuthenticated && renewalInfo?.isTrap && ( +
+ +
+

Renewal Trap Detected

+

+ The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}). + Consider the total cost of ownership before registering. +

+
+
+ )} + + {/* Price Chart */} +
+
+
+

Price History

+ {isAuthenticated && !hasPriceHistory && ( + Pro + )} +
+ {hasPriceHistory && ( +
+ {(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => ( + + ))} +
+ )} +
+ +
+ {!isAuthenticated ? ( +
+
+
+ + Sign in to view price history + + Sign in → + +
+
+ ) : !hasPriceHistory ? ( +
+
+
+ + Price history requires Trader or Tycoon plan + + + Upgrade to Unlock + +
+
+ ) : ( + <> + + + {filteredHistory.length > 0 && ( +
+ + {new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + +
+
+ High + ${chartStats.high.toFixed(2)} +
+
+ Low + ${chartStats.low.toFixed(2)} +
+
+ Today +
+ )} + + )} +
+
+ + {/* Domain Search */} +
+

+ Check .{details.tld} Availability +

+
+
+
+ setDomainSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()} + placeholder="Enter domain name" + className="w-full px-4 py-3.5 pr-20 bg-background border border-border rounded-xl text-body text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all" + /> + + .{tld} + +
+ +
+ + {domainResult && ( + setDomainResult(null)} + /> + )} +
+
+ + {/* Registrar Comparison */} +
+

Compare Registrars

+ + {isAuthenticated ? ( +
+
+ + + + + + + + + + + + {details.registrars.map((registrar, i) => { + const hasRenewalTrap = registrar.renewal_price / registrar.registration_price > 1.5 + const isBestValue = i === 0 && !hasRenewalTrap + + return ( + + + + + + + + ) + })} + +
+ Registrar + + Register + + Renew + + Transfer +
+
+ {registrar.name} + {isBestValue && ( + + Best + + )} + {i === 0 && hasRenewalTrap && ( + + Cheap Start + + )} +
+
+ + ${registrar.registration_price.toFixed(2)} + + + + ${registrar.renewal_price.toFixed(2)} + + {hasRenewalTrap && ( + + + + )} + + + ${registrar.transfer_price.toFixed(2)} + + + + Visit + + +
+
+
+ ) : ( +
+
+ {[1, 2, 3, 4].map(i => ( +
+ + + + +
+ ))} +
+
+
+ +

Sign in to compare registrar prices

+ + Join the Hunt + +
+
+
+ )} +
+ + {/* TLD Info */} +
+

About .{details.tld}

+
+
+ +

Registry

+

{details.registry}

+
+
+ +

Introduced

+

{details.introduced || 'Unknown'}

+
+
+ +

Type

+

{details.type}

+
+
+
+ + {/* Related TLDs */} + {relatedTlds.length > 0 && ( +
+

Similar Extensions

+
+ {relatedTlds.map(related => ( + +

+ .{related.tld} +

+ {isAuthenticated ? ( +

+ from ${related.price.toFixed(2)}/yr +

+ ) : ( + + )} + + ))} +
+
+ )} + + {/* CTA */} +
+

+ Track .{details.tld} Domains +

+

+ Monitor specific domains and get instant notifications when they become available. +

+ + {isAuthenticated ? 'Go to Command Center' : 'Start Monitoring Free'} + + +
+ +
+
+ +
+
+ ) +} diff --git a/frontend/src/app/intel/page.tsx b/frontend/src/app/intel/page.tsx new file mode 100644 index 0000000..9637c8e --- /dev/null +++ b/frontend/src/app/intel/page.tsx @@ -0,0 +1,547 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Header } from '@/components/Header' +import { Footer } from '@/components/Footer' +import { PremiumTable } from '@/components/PremiumTable' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { + TrendingUp, + ChevronRight, + Search, + X, + Lock, + Globe, + AlertTriangle, + ArrowUpDown, +} from 'lucide-react' +import Link from 'next/link' +import clsx from 'clsx' + +interface TldData { + tld: string + type: string + description: string + avg_registration_price: number + min_registration_price: number + max_registration_price: number + min_renewal_price: number + avg_renewal_price: number + registrar_count: number + trend: string + price_change_7d: number + price_change_1y: number + price_change_3y: number + risk_level: 'low' | 'medium' | 'high' + risk_reason: string + popularity_rank?: number +} + +interface TrendingTld { + tld: string + reason: string + price_change: number + current_price: number +} + +interface PaginationData { + total: number + limit: number + offset: number + has_more: boolean +} + +// TLDs that are shown completely to non-authenticated users (gemäß pounce_public.md) +const PUBLIC_PREVIEW_TLDS = ['com', 'net', 'org'] + +// Sparkline component +function Sparkline({ trend }: { trend: number }) { + const isPositive = trend > 0 + const isNeutral = trend === 0 + + return ( +
+ + {isNeutral ? ( + + ) : isPositive ? ( + + ) : ( + + )} + +
+ ) +} + +export default function IntelPage() { + const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() + const [tlds, setTlds] = useState([]) + const [trending, setTrending] = useState([]) + const [loading, setLoading] = useState(true) + const [pagination, setPagination] = useState({ total: 0, limit: 50, offset: 0, has_more: false }) + + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [sortBy, setSortBy] = useState('popularity') + const [page, setPage] = useState(0) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchQuery) + }, 300) + return () => clearTimeout(timer) + }, [searchQuery]) + + useEffect(() => { + checkAuth() + loadTrending() + }, [checkAuth]) + + useEffect(() => { + loadTlds() + }, [debouncedSearch, sortBy, page]) + + const loadTlds = async () => { + setLoading(true) + try { + const data = await api.getTldOverview( + 50, + page * 50, + sortBy as 'popularity' | 'price_asc' | 'price_desc' | 'name', + debouncedSearch || undefined + ) + + setTlds(data?.tlds || []) + setPagination({ + total: data?.total || 0, + limit: 50, + offset: page * 50, + has_more: data?.has_more || false, + }) + } catch (error) { + console.error('Failed to load TLD data:', error) + setTlds([]) + } finally { + setLoading(false) + } + } + + const loadTrending = async () => { + try { + const data = await api.getTrendingTlds() + setTrending(data?.trending || []) + } catch (error) { + console.error('Failed to load trending:', error) + } + } + + // Check if TLD should show full data for non-authenticated users + // Gemäß pounce_public.md: .com, .net, .org are fully visible + const isPublicPreviewTld = (tld: TldData) => { + return PUBLIC_PREVIEW_TLDS.includes(tld.tld.toLowerCase()) + } + + const getRiskBadge = (tld: TldData) => { + const level = tld.risk_level || 'low' + const reason = tld.risk_reason || 'Stable' + return ( + + + {reason} + + ) + } + + const getRenewalTrap = (tld: TldData) => { + if (!tld.min_renewal_price || !tld.min_registration_price) return null + const ratio = tld.min_renewal_price / tld.min_registration_price + if (ratio > 2) { + return ( + + + + ) + } + return null + } + + const currentPage = Math.floor(pagination.offset / pagination.limit) + 1 + const totalPages = Math.ceil(pagination.total / pagination.limit) + + if (authLoading) { + return ( +
+
+
+ ) + } + + return ( +
+ {/* Background Effects */} +
+
+
+
+
+ +
+ +
+
+ {/* Header - gemäß pounce_public.md: "TLD Market Inflation Monitor" */} +
+
+ + Real-time Market Data +
+

+ TLD Market + Inflation Monitor +

+

+ Don't fall for promo prices. See renewal costs, spot traps, and track price trends across {pagination.total}+ extensions. +

+ + {/* Top Movers Cards */} +
+
+ + Renewal Trap Detection +
+
+
+ + + +
+ Risk Levels +
+
+ + 1y/3y Trends +
+
+
+ + {/* Login Banner for non-authenticated users */} + {!isAuthenticated && ( +
+
+
+
+ +
+
+

Stop overpaying. Know the true costs.

+

+ Unlock renewal prices and risk analysis for all {pagination.total}+ TLDs. +

+
+
+ + Start Hunting + +
+
+ )} + + {/* Trending Section - Top Movers */} + {trending.length > 0 && ( +
+

+ + Top Movers +

+
+ {trending.map((item) => ( + +
+ .{item.tld} + 0 + ? "text-[#f97316] bg-[#f9731615]" + : "text-accent bg-accent-muted" + )}> + {item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}% + +
+

+ {item.reason} +

+
+ + ${item.current_price.toFixed(2)}/yr + + +
+ + ))} +
+
+ )} + + {/* Search & Sort Controls */} +
+
+ + { + setSearchQuery(e.target.value) + setPage(0) + }} + className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl + text-body text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent + transition-all duration-300" + /> + {searchQuery && ( + + )} +
+ +
+ + +
+
+ + {/* TLD Table - gemäß pounce_public.md: + - .com, .net, .org: vollständig sichtbar + - Alle anderen: Buy Price + Trend sichtbar, Renewal + Risk geblurrt */} + tld.tld} + loading={loading} + onRowClick={(tld) => { + if (isAuthenticated) { + window.location.href = `/intel/${tld.tld}` + } else { + window.location.href = `/login?redirect=/intel/${tld.tld}` + } + }} + emptyIcon={} + emptyTitle="No TLDs found" + emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"} + columns={[ + { + key: 'tld', + header: 'TLD', + width: '100px', + render: (tld) => ( +
+ + .{tld.tld} + +
+ ), + }, + { + key: 'buy_price', + header: 'Current Price', + align: 'right', + width: '120px', + // Buy price is visible for all TLDs (gemäß pounce_public.md) + render: (tld) => ( + + ${tld.min_registration_price.toFixed(2)} + + ), + }, + { + key: 'trend', + header: 'Trend (1y)', + width: '100px', + hideOnMobile: true, + // Trend is visible for all TLDs + render: (tld) => { + const change = tld.price_change_1y || 0 + return ( +
+ + 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted" + )}> + {change > 0 ? '+' : ''}{change.toFixed(0)}% + +
+ ) + }, + }, + { + key: 'renew_price', + header: 'Renewal Price', + align: 'right', + width: '130px', + // Renewal price: visible for .com/.net/.org OR authenticated users + // Geblurrt/Locked für alle anderen + render: (tld) => { + const showData = isAuthenticated || isPublicPreviewTld(tld) + if (!showData) { + return ( +
+ $XX.XX + +
+ ) + } + return ( +
+ + ${tld.min_renewal_price?.toFixed(2) || '—'} + + {getRenewalTrap(tld)} +
+ ) + }, + }, + { + key: 'risk', + header: 'Risk Level', + align: 'center', + width: '140px', + // Risk: visible for .com/.net/.org OR authenticated users + // Geblurrt/Locked für alle anderen + render: (tld) => { + const showData = isAuthenticated || isPublicPreviewTld(tld) + if (!showData) { + return ( +
+ + + Hidden + + +
+ ) + } + return getRiskBadge(tld) + }, + }, + { + key: 'actions', + header: '', + align: 'right', + width: '80px', + render: () => ( + + ), + }, + ]} + /> + + {/* Pagination */} + {!loading && pagination.total > pagination.limit && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} + + {/* Stats */} + {!loading && ( +
+

+ {searchQuery + ? `Found ${pagination.total} TLDs matching "${searchQuery}"` + : `${pagination.total} TLDs tracked` + } +

+
+ )} +
+
+ +
+
+ ) +} + diff --git a/frontend/src/app/market/page.tsx b/frontend/src/app/market/page.tsx index 7043c97..9a928da 100644 --- a/frontend/src/app/market/page.tsx +++ b/frontend/src/app/market/page.tsx @@ -1,25 +1,730 @@ 'use client' -import { useEffect } from 'react' -import { useRouter } from 'next/navigation' +import { useEffect, useState, useMemo } from 'react' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { Header } from '@/components/Header' +import { Footer } from '@/components/Footer' +import { PlatformBadge } from '@/components/PremiumTable' +import { + Clock, + ExternalLink, + Search, + Flame, + Timer, + Gavel, + DollarSign, + X, + Lock, + TrendingUp, + ChevronUp, + ChevronDown, + ChevronsUpDown, + Sparkles, + Diamond, + ShieldCheck, + Zap, + Filter, +} from 'lucide-react' +import Link from 'next/link' +import clsx from 'clsx' -/** - * Redirect /market to /auctions - * This page is kept for backwards compatibility - */ -export default function MarketRedirect() { - const router = useRouter() +interface MarketItem { + id: string + domain: string + tld: string + price: number + currency: string + price_type: 'bid' | 'fixed' | 'negotiable' + status: 'auction' | 'instant' + source: string + is_pounce: boolean + verified: boolean + time_remaining?: string + end_time?: string + num_bids?: number + slug?: string + seller_verified: boolean + url: string + is_external: boolean + pounce_score: number +} + +interface Auction { + domain: string + platform: string + platform_url: string + current_bid: number + currency: string + num_bids: number + end_time: string + time_remaining: string + buy_now_price: number | null + reserve_met: boolean | null + traffic: number | null + age_years: number | null + tld: string + affiliate_url: string +} + +type TabType = 'all' | 'ending' | 'hot' +type SortField = 'domain' | 'ending' | 'bid' | 'bids' +type SortDirection = 'asc' | 'desc' + +const PLATFORMS = [ + { id: 'All', name: 'All Sources' }, + { id: 'GoDaddy', name: 'GoDaddy' }, + { id: 'Sedo', name: 'Sedo' }, + { id: 'NameJet', name: 'NameJet' }, + { id: 'DropCatch', name: 'DropCatch' }, +] + +// Premium TLDs that look professional +const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz'] + +// Vanity Filter: Only show "beautiful" domains to non-authenticated users +// Rules: No numbers (except short domains), no hyphens, length < 12, only premium TLDs +function isVanityDomain(auction: Auction): boolean { + const domain = auction.domain + const parts = domain.split('.') + if (parts.length < 2) return false + const name = parts[0] + const tld = parts.slice(1).join('.').toLowerCase() + + // Check TLD is premium + if (!PREMIUM_TLDS.includes(tld)) return false + + // Check length (max 12 characters for the name) + if (name.length > 12) return false + + // No hyphens + if (name.includes('-')) return false + + // No numbers (unless domain is 4 chars or less - short domains are valuable) + if (name.length > 4 && /\d/.test(name)) return false + + return true +} + +// Generate a mock "Deal Score" for display purposes +// In production, this would come from a valuation API +function getDealScore(auction: Auction): number | null { + let score = 50 + + const name = auction.domain.split('.')[0] + if (name.length <= 4) score += 20 + else if (name.length <= 6) score += 10 + + if (['com', 'io', 'ai'].includes(auction.tld)) score += 15 + + if (auction.age_years && auction.age_years > 5) score += 10 + + if (auction.num_bids >= 20) score += 15 + else if (auction.num_bids >= 10) score += 10 + + return Math.min(score, 100) +} + +function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) { + if (field !== currentField) { + return + } + return direction === 'asc' + ? + : +} + +export default function MarketPage() { + const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() + + const [allAuctions, setAllAuctions] = useState([]) + const [endingSoon, setEndingSoon] = useState([]) + const [hotAuctions, setHotAuctions] = useState([]) + const [pounceItems, setPounceItems] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState('all') + const [sortField, setSortField] = useState('ending') + const [sortDirection, setSortDirection] = useState('asc') + + const [searchQuery, setSearchQuery] = useState('') + const [selectedPlatform, setSelectedPlatform] = useState('All') + const [maxBid, setMaxBid] = useState('') + useEffect(() => { - router.replace('/auctions') - }, [router]) - - return ( -
-
-
-

Redirecting to Market...

+ checkAuth() + loadAuctions() + }, [checkAuth]) + + const loadAuctions = async () => { + setLoading(true) + try { + const [allFeed, endingFeed, hotFeed, pounceFeed] = await Promise.all([ + api.getMarketFeed({ source: 'all', limit: 100, sortBy: 'time' }), + api.getMarketFeed({ source: 'external', endingWithin: 24, limit: 50, sortBy: 'time' }), + api.getMarketFeed({ source: 'external', limit: 50, sortBy: 'score' }), + api.getMarketFeed({ source: 'pounce', limit: 10 }), + ]) + + const convertToAuction = (item: MarketItem): Auction => ({ + domain: item.domain, + platform: item.source, + platform_url: item.url, + current_bid: item.price, + currency: item.currency, + num_bids: item.num_bids || 0, + end_time: item.end_time || '', + time_remaining: item.time_remaining || '', + buy_now_price: item.price_type === 'fixed' ? item.price : null, + reserve_met: null, + traffic: null, + age_years: null, + tld: item.tld, + affiliate_url: item.url, + }) + + const externalOnly = (items: MarketItem[]) => items.filter(i => !i.is_pounce).map(convertToAuction) + + setAllAuctions(externalOnly(allFeed.items || [])) + setEndingSoon(externalOnly(endingFeed.items || [])) + setHotAuctions(externalOnly(hotFeed.items || [])) + setPounceItems(pounceFeed.items || []) + } catch (error) { + console.error('Failed to load auctions:', error) + } finally { + setLoading(false) + } + } + + const getCurrentAuctions = (): Auction[] => { + switch (activeTab) { + case 'ending': return endingSoon + case 'hot': return hotAuctions + default: return allAuctions + } + } + + // Apply Vanity Filter for non-authenticated users + const displayAuctions = useMemo(() => { + const current = getCurrentAuctions() + if (isAuthenticated) { + return current + } + return current.filter(isVanityDomain) + }, [activeTab, allAuctions, endingSoon, hotAuctions, isAuthenticated]) + + const filteredAuctions = displayAuctions.filter(auction => { + if (searchQuery && !auction.domain.toLowerCase().includes(searchQuery.toLowerCase())) { + return false + } + if (selectedPlatform !== 'All' && auction.platform !== selectedPlatform) { + return false + } + if (maxBid && auction.current_bid > parseFloat(maxBid)) { + return false + } + return true + }) + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') + } else { + setSortField(field) + setSortDirection('asc') + } + } + + const sortedAuctions = [...filteredAuctions].sort((a, b) => { + const modifier = sortDirection === 'asc' ? 1 : -1 + switch (sortField) { + case 'domain': + return a.domain.localeCompare(b.domain) * modifier + case 'bid': + return (a.current_bid - b.current_bid) * modifier + case 'bids': + return (a.num_bids - b.num_bids) * modifier + default: + return 0 + } + }) + + const formatCurrency = (amount: number, currency = 'USD') => { + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount) + } + + const getTimeColor = (timeRemaining: string) => { + if (timeRemaining.includes('m') && !timeRemaining.includes('h')) return 'text-red-400' + if (timeRemaining.includes('h') && parseInt(timeRemaining) < 12) return 'text-amber-400' + return 'text-foreground-muted' + } + + const hotPreview = hotAuctions.slice(0, 4) + + if (authLoading) { + return ( +
+
+ ) + } + + return ( +
+ {/* Background Effects */} +
+
+
+
+
+ +
+ +
+
+ {/* Hero Header - gemäß pounce_public.md */} +
+ Live Market +

+ Live Domain Market +

+

+ Aggregated from GoDaddy, Sedo, and Pounce Direct. +

+ {!isAuthenticated && displayAuctions.length < allAuctions.length && ( +

+ + Showing {displayAuctions.length} premium domains • Sign in to see all {allAuctions.length} +

+ )} +
+ + {/* Login Banner for non-authenticated users */} + {!isAuthenticated && ( +
+
+
+ +
+
+

Unlock Smart Opportunities

+

+ Sign in for valuations, deal scores, and personalized recommendations. +

+
+
+ + Start Hunting + +
+ )} + + {/* Pounce Direct Section - Featured */} + {pounceItems.length > 0 && ( +
+
+
+ +

+ Pounce Direct +

+
+ Verified • Instant Buy • 0% Commission +
+
+ {pounceItems.map((item) => ( + +
+ +
+
+ {item.domain} +
+
+ {item.verified && ( + + + Verified + + )} + {isAuthenticated ? ( + + Score: {item.pounce_score} + + ) : ( + + Score: XX + + )} +
+
+
+
+
+
+ {formatCurrency(item.price, item.currency)} +
+
Instant Buy
+
+
+ Buy Now + +
+
+ + ))} +
+
+ + Browse all Pounce listings → + +
+
+ )} + + {/* Hot Auctions Preview */} + {hotPreview.length > 0 && ( + + )} + + {/* Search & Filters */} +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl + text-body text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent + transition-all duration-300" + /> + {searchQuery && ( + + )} +
+ +
+ + setMaxBid(e.target.value)} + className="w-32 pl-10 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl + text-body text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent" + /> +
+
+
+ + {/* Tabs */} +
+ {[ + { id: 'all' as const, label: 'All Auctions', icon: Gavel, count: allAuctions.length }, + { id: 'ending' as const, label: 'Ending Soon', icon: Timer, count: endingSoon.length }, + { id: 'hot' as const, label: 'Hot', icon: Flame, count: hotAuctions.length }, + ].map((tab) => ( + + ))} +
+ + {/* Auctions Table */} +
+
+ + + + + + + {/* Pounce Score Column - visible but blurred for non-auth (gemäß pounce_public.md) */} + + {/* Valuation Column - visible but blurred for non-auth */} + + + + + + + {loading ? ( + Array.from({ length: 10 }).map((_, idx) => ( + + + + + + + + + + )) + ) : sortedAuctions.length === 0 ? ( + + + + ) : ( + sortedAuctions.map((auction) => ( + + + + + {/* Pounce Score - blurred for non-authenticated (gemäß pounce_public.md) */} + + {/* Valuation - blurred for non-authenticated */} + + + + + )) + )} + +
+ + + Source + + + + + Pounce Score + {!isAuthenticated && } + + + + Valuation + {!isAuthenticated && } + + + +
+ {searchQuery ? `No domains found matching "${searchQuery}"` : 'No domains found'} +
+ + +
+ + {auction.age_years && ( + + {auction.age_years}y + + )} +
+
+
+ + {formatCurrency(auction.current_bid)} + + {auction.buy_now_price && ( +

Buy: {formatCurrency(auction.buy_now_price)}

+ )} +
+
+ {isAuthenticated ? ( +
+ = 75 ? "bg-accent/20 text-accent" : + (getDealScore(auction) ?? 0) >= 50 ? "bg-amber-500/20 text-amber-400" : + "bg-foreground/10 text-foreground-muted" + )}> + {getDealScore(auction)} + + {(getDealScore(auction) ?? 0) >= 75 && ( + Good Deal + )} +
+ ) : ( +
+ XX +
+ )} +
+ {isAuthenticated ? ( + + ${(auction.current_bid * 1.5).toFixed(0)} + + ) : ( + + $X,XXX + + )} + + + {auction.time_remaining} + + + + Bid + + +
+
+
+ + {/* Stats */} + {!loading && ( +
+

+ {searchQuery + ? `Found ${sortedAuctions.length} domains matching "${searchQuery}"` + : `${allAuctions.length} domains available across ${PLATFORMS.length - 1} platforms` + } +

+
+ )} + + {/* Bottom CTA for upgrade (gemäß pounce_public.md) */} + {!isAuthenticated && ( +
+
+ +

Tired of digging through spam?

+
+

+ Our 'Trader' plan filters 99% of junk domains automatically. See only premium opportunities. +

+ + Upgrade Filter + + +
+ )} +
+
+ +
) } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 383548d..b50e9dd 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -261,6 +261,82 @@ export default function HomePage() { )} + {/* Live Market Teaser - gemäß pounce_public.md */} + {!isAuthenticated && !loadingAuctions && hotAuctions.length > 0 && ( +
+
+
+

+ + Live Market Preview +

+ + View All + +
+ + {/* Mini Table with blur on last row */} +
+ + + + + + + + + + {hotAuctions.slice(0, 4).map((auction, idx) => ( + + + + + + ))} + {/* Blurred last row - the hook */} + {hotAuctions.length > 4 && ( + + + + )} + +
DomainPriceTime Left
+ {auction.domain} + + ${auction.current_bid} + + {auction.time_remaining} +
+
+ {hotAuctions[4]?.domain || 'premium.io'} + $XXX + Xh Xm +
+
+ + {/* Sign in CTA overlay */} +
+
+ + + Sign in to see {hotAuctions.length - 4}+ more domains + + + Start Hunting → + +
+
+
+
+
+ )} + {/* Three Pillars: DISCOVER, TRACK, ACQUIRE */}
@@ -582,10 +658,10 @@ export default function HomePage() {
- Explore TLD Pricing + Explore Intel
@@ -606,7 +682,7 @@ export default function HomePage() { {trendingTlds.map((item, index) => (
-
- - Marketplace - - -
+
{/* Messages */} @@ -396,15 +388,37 @@ export default function MyListingsPage() {
-

Unlock Portfolio Management

+

Unlock Domain Selling

- List your domains, verify ownership automatically, and sell directly to buyers with 0% commission on the Pounce Marketplace. + List your domains with 0% commission on the Pounce Marketplace.

+ + {/* Plan comparison */} +
+
+
+ + Trader +
+

$9/month

+

5 Listings

+
+
+
+ + Tycoon +
+

$29/month

+

50 Listings

+

+ Featured Badge

+
+
+ - Upgrade to Trader + Upgrade Now
@@ -424,7 +438,7 @@ export default function MyListingsPage() { label="Active Listings" value={activeCount} subValue="Live on market" - icon={Store} + icon={Globe} trend="active" /> ('watching') @@ -234,6 +248,44 @@ export default function WatchlistPage() { const [loadingHealth, setLoadingHealth] = useState>({}) const [selectedHealthDomainId, setSelectedHealthDomainId] = useState(null) + // Listing state for tier limits + const [listings, setListings] = useState([]) + const [loadingListings, setLoadingListings] = useState(false) + + // Sell Modal state (Wizard) + const [showSellModal, setShowSellModal] = useState(false) + const [sellStep, setSellStep] = useState<1 | 2 | 3>(1) // 1: Details, 2: DNS Verify, 3: Published + const [sellDomainName, setSellDomainName] = useState('') + const [sellForm, setSellForm] = useState({ + price: '', + priceType: 'negotiable', + allowOffers: true, + title: '', + }) + const [sellLoading, setSellLoading] = useState(false) + const [sellListingId, setSellListingId] = useState(null) + const [sellVerificationInfo, setSellVerificationInfo] = useState<{ + verification_code: string + dns_record_name: string + dns_record_value: string + } | null>(null) + const [copiedField, setCopiedField] = useState(null) + + // Tier-based limits (from pounce_pricing.md) + const tier = subscription?.tier || 'scout' + + // Watchlist limits: Scout=5, Trader=50, Tycoon=500 + const watchlistLimits: Record = { scout: 5, trader: 50, tycoon: 500 } + const maxWatchlist = watchlistLimits[tier] || 5 + + // Listing limits: Scout=0, Trader=5, Tycoon=50 + const listingLimits: Record = { scout: 0, trader: 5, tycoon: 50 } + const maxListings = listingLimits[tier] || 0 + const canSell = tier !== 'scout' + const isTycoon = tier === 'tycoon' + const currentListingCount = listings.length + const canCreateListing = canSell && currentListingCount < maxListings + // Memoized stats const stats = useMemo(() => { @@ -250,11 +302,11 @@ export default function WatchlistPage() { available: available.length, expiringSoon: expiringSoon.length, critical, - limit: subscription?.domain_limit || 5, + limit: maxWatchlist, } - }, [domains, subscription?.domain_limit, healthReports]) + }, [domains, maxWatchlist, healthReports]) - const canAddMore = stats.total < stats.limit || stats.limit === -1 + const canAddMore = stats.total < stats.limit // Memoized filtered domains const filteredDomains = useMemo(() => { @@ -391,6 +443,116 @@ export default function WatchlistPage() { loadHealthData() }, [domains]) + // Load user's listings to check limits + useEffect(() => { + const loadListings = async () => { + if (!canSell) return // Scout users can't list, no need to load + + setLoadingListings(true) + try { + const data = await api.request('/listings/my') + setListings(data) + } catch (err) { + console.error('Failed to load listings:', err) + } finally { + setLoadingListings(false) + } + } + + loadListings() + }, [canSell]) + + // Handle "Sell" button click - open wizard modal + const handleSellDomain = useCallback((domainName: string) => { + if (!canSell) { + showToast('Upgrade to Trader or Tycoon to sell domains', 'error') + return + } + + if (!canCreateListing) { + showToast(`Listing limit reached (${currentListingCount}/${maxListings}). Upgrade to list more.`, 'error') + return + } + + // Open sell wizard modal + setSellDomainName(domainName) + setSellStep(1) + setSellForm({ price: '', priceType: 'negotiable', allowOffers: true, title: '' }) + setSellListingId(null) + setSellVerificationInfo(null) + setShowSellModal(true) + }, [canSell, canCreateListing, currentListingCount, maxListings, showToast]) + + // Step 1: Create listing + const handleCreateListing = useCallback(async () => { + setSellLoading(true) + try { + const response = await api.request<{ id: number }>('/listings', { + method: 'POST', + body: JSON.stringify({ + domain: sellDomainName, + title: sellForm.title || null, + asking_price: sellForm.price ? parseFloat(sellForm.price) : null, + price_type: sellForm.priceType, + allow_offers: sellForm.allowOffers, + }), + }) + setSellListingId(response.id) + + // Start DNS verification + const verifyResponse = await api.request<{ + verification_code: string + dns_record_name: string + dns_record_value: string + }>(`/listings/${response.id}/verify-dns`, { method: 'POST' }) + + setSellVerificationInfo(verifyResponse) + setSellStep(2) + showToast('Listing created! Now verify ownership.', 'success') + } catch (err: any) { + showToast(err.message || 'Failed to create listing', 'error') + } finally { + setSellLoading(false) + } + }, [sellDomainName, sellForm, showToast]) + + // Step 2: Check DNS verification + const handleCheckVerification = useCallback(async () => { + if (!sellListingId) return + setSellLoading(true) + try { + const result = await api.request<{ verified: boolean; message: string }>( + `/listings/${sellListingId}/verify-dns/check` + ) + + if (result.verified) { + // Publish the listing + await api.request(`/listings/${sellListingId}`, { + method: 'PUT', + body: JSON.stringify({ status: 'active' }), + }) + setSellStep(3) + showToast('Domain verified and published!', 'success') + // Reload listings + const data = await api.request('/listings/my') + setListings(data) + } else { + showToast(result.message || 'Verification pending. Check your DNS settings.', 'error') + } + } catch (err: any) { + showToast(err.message || 'Verification failed', 'error') + } finally { + setSellLoading(false) + } + }, [sellListingId, showToast]) + + // Copy to clipboard helper + const copyToClipboard = useCallback((text: string, field: string) => { + navigator.clipboard.writeText(text) + setCopiedField(field) + setTimeout(() => setCopiedField(null), 2000) + }, []) + // Portfolio uses the SAME domains as Watchlist - just a different view // This ensures consistent monitoring behavior @@ -1144,12 +1306,44 @@ export default function WatchlistPage() { } - - Sell - + + {/* Sell Button - Tier-based */} + {canSell ? ( + + + + ) : ( + + + + Upgrade + + + )}
) @@ -1257,12 +1451,33 @@ export default function WatchlistPage() { }
- - Sell - + {/* Sell Button - Tier-based (Mobile) */} + {canSell ? ( + + ) : ( + + + Upgrade + + )}
) @@ -1272,7 +1487,7 @@ export default function WatchlistPage() { )}
- {/* Portfolio Footer */} + {/* Portfolio Footer with Listing Info */}
@@ -1282,6 +1497,19 @@ export default function WatchlistPage() { Get alerts for changes + {canSell && ( + + + {currentListingCount}/{maxListings} listings + {isTycoon && } + + )} + {!canSell && ( + + + Upgrade to sell + + )}
)} @@ -1294,6 +1522,256 @@ export default function WatchlistPage() { onClose={() => setSelectedHealthDomainId(null)} /> )} + + {/* Sell Wizard Modal */} + {showSellModal && ( +
setShowSellModal(false)} + > +
e.stopPropagation()} + > + {/* Header with Steps */} +
+
+
+
+ +
+
+

List for Sale

+

{sellDomainName}

+
+
+ +
+ + {/* Step Indicator */} +
+ {[1, 2, 3].map((step) => ( +
+
= step + ? "bg-emerald-500 text-black border-emerald-500" + : "bg-zinc-800 text-zinc-500 border-zinc-700" + )}> + {sellStep > step ? : step} +
+ + {step < 3 &&
step ? "bg-emerald-500" : "bg-zinc-800")} />} +
+ ))} +
+
+ + {/* Step 1: Listing Details */} + {sellStep === 1 && ( +
+
+ + setSellForm({ ...sellForm, title: e.target.value })} + placeholder="e.g. Perfect for AI Startups" + className="w-full px-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" + /> +
+ +
+
+ +
+ + setSellForm({ ...sellForm, price: e.target.value })} + placeholder="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-emerald-500/50 transition-all font-mono" + /> +
+
+ +
+ + +
+
+ + + + {/* Limits Info */} +
+ Your listing slots: + {currentListingCount}/{maxListings} +
+ +
+ + +
+
+ )} + + {/* Step 2: DNS Verification */} + {sellStep === 2 && sellVerificationInfo && ( +
+
+
+ +
+

Verify Ownership

+

+ Add this TXT record to your domain's DNS to prove ownership. +

+
+
+
+ +
+
+
Type
+
+ TXT +
+
+ +
+
Name / Host
+
copyToClipboard(sellVerificationInfo.dns_record_name, 'name')} + > + {sellVerificationInfo.dns_record_name} + {copiedField === 'name' + ? + : + } +
+
+ +
+
Value
+
copyToClipboard(sellVerificationInfo.dns_record_value, 'value')} + > + {sellVerificationInfo.dns_record_value} + {copiedField === 'value' + ? + : + } +
+
+
+ +
+

+ 💡 DNS changes can take up to 24 hours to propagate, but usually work within minutes. +

+
+ +
+ + +
+
+ )} + + {/* Step 3: Success */} + {sellStep === 3 && ( +
+
+ +
+ +
+

Domain Listed!

+

+ {sellDomainName} is now live on the Pounce Marketplace. +

+
+ + {isTycoon && ( +
+ + Featured Listing Active +
+ )} + +
+ + + View Listings + +
+
+ )} +
+
+ )}
) diff --git a/frontend/src/app/tld-pricing/[tld]/page.tsx b/frontend/src/app/tld-pricing/[tld]/page.tsx index a058154..c82a629 100644 --- a/frontend/src/app/tld-pricing/[tld]/page.tsx +++ b/frontend/src/app/tld-pricing/[tld]/page.tsx @@ -1,1187 +1,27 @@ 'use client' -import { useEffect, useState, useMemo, useRef } from 'react' -import { useParams } from 'next/navigation' -import { Header } from '@/components/Header' -import { Footer } from '@/components/Footer' -import { useStore } from '@/lib/store' -import { api } from '@/lib/api' -import { - ArrowLeft, - TrendingUp, - TrendingDown, - Minus, - Calendar, - Globe, - Building, - ExternalLink, - Search, - ChevronRight, - Check, - X, - Lock, - RefreshCw, - Clock, - Shield, - Zap, - AlertTriangle, -} from 'lucide-react' -import Link from 'next/link' -import clsx from 'clsx' +import { useEffect } from 'react' +import { useRouter, useParams } from 'next/navigation' -interface TldDetails { - tld: string - type: string - description: string - registry: string - introduced: number - trend: string - trend_reason: string - pricing: { - avg: number - min: number - max: number - } - registrars: Array<{ - name: string - registration_price: number - renewal_price: number - transfer_price: number - }> - cheapest_registrar: string - // New fields from table - min_renewal_price: number - price_change_1y: number - price_change_3y: number - risk_level: 'low' | 'medium' | 'high' - risk_reason: string -} - -interface TldHistory { - tld: string - type?: string - description?: string - registry?: string - current_price: number - price_change_7d: number - price_change_30d: number - price_change_90d: number - trend: string - trend_reason: string - history: Array<{ - date: string - price: number - }> - source?: string -} - -interface DomainCheckResult { - domain: string - is_available: boolean - status: string - registrar?: string | null - creation_date?: string | null - expiration_date?: string | null -} - -// Registrar URLs -const REGISTRAR_URLS: Record = { - 'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=', - 'Porkbun': 'https://porkbun.com/checkout/search?q=', - 'Cloudflare': 'https://www.cloudflare.com/products/registrar/', - 'Google Domains': 'https://domains.google.com/registrar/search?searchTerm=', - 'GoDaddy': 'https://www.godaddy.com/domainsearch/find?domainToCheck=', - 'porkbun': 'https://porkbun.com/checkout/search?q=', - 'Dynadot': 'https://www.dynadot.com/domain/search?domain=', - 'Hover': 'https://www.hover.com/domains/results?q=', -} - -// Related TLDs -const RELATED_TLDS: Record = { - 'com': ['net', 'org', 'co', 'io'], - 'net': ['com', 'org', 'io', 'dev'], - 'org': ['com', 'net', 'ngo', 'foundation'], - 'io': ['dev', 'app', 'tech', 'ai'], - 'ai': ['io', 'tech', 'dev', 'ml'], - 'dev': ['io', 'app', 'tech', 'code'], - 'app': ['dev', 'io', 'mobile', 'software'], - 'co': ['com', 'io', 'biz', 'inc'], - 'de': ['at', 'ch', 'eu', 'com'], - 'ch': ['de', 'at', 'li', 'eu'], -} - -type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL' - -// Shimmer component for unauthenticated users -function Shimmer({ className }: { className?: string }) { - return ( -
-
-
- ) -} - -// Premium Chart Component with real data -function PriceChart({ - data, - isAuthenticated, - chartStats, -}: { - data: Array<{ date: string; price: number }> - isAuthenticated: boolean - chartStats: { high: number; low: number; avg: number } -}) { - const [hoveredIndex, setHoveredIndex] = useState(null) - const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }) - const containerRef = useRef(null) - - if (!isAuthenticated) { - return ( -
-
- -
- - Sign in to view price history -
-
- ) - } - - if (data.length === 0) { - return ( -
- No price history available -
- ) - } - - const range = chartStats.high - chartStats.low || 1 - const padding = range * 0.1 - - // Create smooth path - const points = data.map((point, i) => { - const x = (i / (data.length - 1)) * 100 - const y = 100 - ((point.price - chartStats.low + padding) / (range + padding * 2)) * 100 - return { x, y, ...point } - }) - - // Create SVG path for smooth curve - const linePath = points.reduce((path, point, i) => { - if (i === 0) return `M ${point.x} ${point.y}` - - // Use bezier curves for smoothness - const prev = points[i - 1] - const cpx = (prev.x + point.x) / 2 - return `${path} C ${cpx} ${prev.y}, ${cpx} ${point.y}, ${point.x} ${point.y}` - }, '') - - // Area path - const areaPath = `${linePath} L 100 100 L 0 100 Z` - - const handleMouseMove = (e: React.MouseEvent) => { - if (!containerRef.current) return - const rect = containerRef.current.getBoundingClientRect() - const x = e.clientX - rect.left - const percentage = x / rect.width - const index = Math.round(percentage * (data.length - 1)) - if (index >= 0 && index < data.length) { - setHoveredIndex(index) - setTooltipPos({ x: e.clientX - rect.left, y: points[index].y * rect.height / 100 }) - } - } - - return ( -
- setHoveredIndex(null)} - > - - - - - - - - - - - - - - - {/* Grid lines */} - {[20, 40, 60, 80].map(y => ( - - ))} - - {/* Area fill */} - - - {/* Main line */} - - - {/* Hover indicator */} - {hoveredIndex !== null && ( - - - - )} - - - {/* Hover dot */} - {hoveredIndex !== null && containerRef.current && ( -
- )} - - {/* Tooltip */} - {hoveredIndex !== null && ( -
-

- ${data[hoveredIndex].price.toFixed(2)} -

-

- {new Date(data[hoveredIndex].date).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - })} -

-
- )} -
- ) -} - -// Domain Check Result Card -function DomainResultCard({ - result, - tld, - cheapestPrice, - cheapestRegistrar, - onClose -}: { - result: DomainCheckResult - tld: string - cheapestPrice: number - cheapestRegistrar: string - onClose: () => void -}) { - const registrarUrl = REGISTRAR_URLS[cheapestRegistrar] || '#' - - return ( -
-
-
-
-
- {result.is_available ? ( - - ) : ( - - )} -
-
-

{result.domain}

-

- {result.is_available ? 'Available for registration' : 'Already registered'} -

-
-
- - {result.is_available ? ( -
-
- - - Register from ${cheapestPrice.toFixed(2)}/yr - -
- - Register at {cheapestRegistrar} - - -
- ) : ( -
- {result.registrar && ( -
- - Registrar: {result.registrar} -
- )} - {result.expiration_date && ( -
- - Expires: {new Date(result.expiration_date).toLocaleDateString()} -
- )} -
- )} -
- - -
-
- ) -} - -export default function TldDetailPage() { +/** + * Redirect /tld-pricing/[tld] to /intel/[tld] + * This page is kept for backwards compatibility + */ +export default function TldDetailRedirect() { + const router = useRouter() const params = useParams() - const { isAuthenticated, checkAuth, isLoading: authLoading, subscription, fetchSubscription } = useStore() const tld = params.tld as string - // Feature flags based on subscription - const hasPriceHistory = (subscription?.history_days ?? 0) !== 0 + useEffect(() => { + router.replace(`/intel/${tld}`) + }, [router, tld]) - const [details, setDetails] = useState(null) - const [history, setHistory] = useState(null) - const [relatedTlds, setRelatedTlds] = useState>([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - const [chartPeriod, setChartPeriod] = useState('1Y') - const [domainSearch, setDomainSearch] = useState('') - const [checkingDomain, setCheckingDomain] = useState(false) - const [domainResult, setDomainResult] = useState(null) - - useEffect(() => { - checkAuth() - fetchSubscription() - }, [checkAuth, fetchSubscription]) - - useEffect(() => { - if (tld) { - loadData() - loadRelatedTlds() - } - }, [tld]) - - const loadData = async () => { - try { - const [historyData, compareData, overviewData] = await Promise.all([ - api.getTldHistory(tld, 365), - api.getTldCompare(tld), - api.getTldOverview(1, 0, 'popularity', tld), - ]) - - if (historyData && compareData) { - const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) => - a.registration_price - b.registration_price - ) - - // Get additional data from overview API - const tldFromOverview = overviewData?.tlds?.[0] - - setDetails({ - tld: compareData.tld || tld, - type: compareData.type || 'generic', - description: compareData.description || `Domain extension .${tld}`, - registry: compareData.registry || 'Various', - introduced: compareData.introduced || 0, - trend: historyData.trend || 'stable', - trend_reason: historyData.trend_reason || 'Price tracking available', - pricing: { - avg: compareData.price_range?.avg || historyData.current_price || 0, - min: compareData.price_range?.min || historyData.current_price || 0, - max: compareData.price_range?.max || historyData.current_price || 0, - }, - registrars: sortedRegistrars, - cheapest_registrar: compareData.cheapest_registrar || sortedRegistrars[0]?.name || 'N/A', - // New fields from overview - min_renewal_price: tldFromOverview?.min_renewal_price || sortedRegistrars[0]?.renewal_price || 0, - price_change_1y: tldFromOverview?.price_change_1y || 0, - price_change_3y: tldFromOverview?.price_change_3y || 0, - risk_level: tldFromOverview?.risk_level || 'low', - risk_reason: tldFromOverview?.risk_reason || 'Stable', - }) - setHistory(historyData) - } else { - setError('Failed to load TLD data') - } - } catch (err) { - console.error('Error loading TLD data:', err) - setError('Failed to load TLD data') - } finally { - setLoading(false) - } - } - - const loadRelatedTlds = async () => { - const related = RELATED_TLDS[tld.toLowerCase()] || ['com', 'net', 'org', 'io'] - const relatedData: Array<{ tld: string; price: number }> = [] - - for (const relatedTld of related.slice(0, 4)) { - try { - const data = await api.getTldHistory(relatedTld, 30) - if (data) { - relatedData.push({ tld: relatedTld, price: data.current_price }) - } - } catch { - // Skip failed - } - } - setRelatedTlds(relatedData) - } - - const filteredHistory = useMemo(() => { - if (!history?.history) return [] - - const now = new Date() - let cutoffDays = 365 - - switch (chartPeriod) { - case '1M': cutoffDays = 30; break - case '3M': cutoffDays = 90; break - case '1Y': cutoffDays = 365; break - case 'ALL': cutoffDays = 9999; break - } - - const cutoff = new Date(now.getTime() - cutoffDays * 24 * 60 * 60 * 1000) - return history.history.filter(h => new Date(h.date) >= cutoff) - }, [history, chartPeriod]) - - const chartStats = useMemo(() => { - if (filteredHistory.length === 0) return { high: 0, low: 0, avg: 0 } - const prices = filteredHistory.map(h => h.price) - return { - high: Math.max(...prices), - low: Math.min(...prices), - avg: prices.reduce((a, b) => a + b, 0) / prices.length, - } - }, [filteredHistory]) - - const handleDomainCheck = async () => { - if (!domainSearch.trim()) return - - setCheckingDomain(true) - setDomainResult(null) - - try { - const domain = domainSearch.includes('.') ? domainSearch : `${domainSearch}.${tld}` - const result = await api.checkDomain(domain, false) - setDomainResult({ - domain, - is_available: result.is_available, - status: result.status, - registrar: result.registrar, - creation_date: result.creation_date, - expiration_date: result.expiration_date, - }) - } catch (err) { - console.error('Domain check failed:', err) - } finally { - setCheckingDomain(false) - } - } - - const getRegistrarUrl = (registrarName: string, domain?: string) => { - const baseUrl = REGISTRAR_URLS[registrarName] - if (!baseUrl) return '#' - if (domain) return `${baseUrl}${domain}` - return baseUrl - } - - const savings = useMemo(() => { - if (!details || details.registrars.length < 2) return null - const cheapest = details.registrars[0].registration_price - const mostExpensive = details.registrars[details.registrars.length - 1].registration_price - return { - amount: mostExpensive - cheapest, - cheapestName: details.registrars[0].name, - expensiveName: details.registrars[details.registrars.length - 1].name, - } - }, [details]) - - // Renewal trap info - const renewalInfo = useMemo(() => { - if (!details?.registrars?.length) return null - const cheapest = details.registrars[0] - const ratio = cheapest.renewal_price / cheapest.registration_price - return { - registration: cheapest.registration_price, - renewal: cheapest.renewal_price, - ratio, - isTrap: ratio > 2, - } - }, [details]) - - // Risk badge component - const getRiskBadge = () => { - if (!details) return null - const level = details.risk_level - const reason = details.risk_reason - return ( - - - {reason} - - ) - } - - const getTrendIcon = (trend: string) => { - switch (trend) { - case 'up': return - case 'down': return - default: return - } - } - - if (loading || authLoading) { - return ( -
-
-
-
- - -
- {[1, 2, 3, 4].map(i => )} -
- -
-
-
- ) - } - - if (error || !details) { - return ( -
-
-
-
-
- -
-

TLD Not Found

-

{error || `The TLD .${tld} could not be found.`}

- - - Back to TLD Overview - -
-
-
-
- ) - } - return ( -
- {/* Subtle ambient */} -
-
+
+
+
+

Redirecting to Intel...

- -
- -
-
- - {/* Breadcrumb */} - - - {/* Hero */} -
- {/* Left: TLD Info */} -
-
-

- .{details.tld} -

-
- {getTrendIcon(details.trend)} - {details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'} -
-
- -

{details.description}

-

{details.trend_reason}

- - {/* Quick Stats - All data from table */} -
-
-

Buy (1y)

- {isAuthenticated ? ( -

${details.pricing.min.toFixed(2)}

- ) : ( - - )} -
-
-

Renew (1y)

- {isAuthenticated ? ( -
-

- ${details.min_renewal_price.toFixed(2)} -

- {renewalInfo?.isTrap && ( - - - - )} -
- ) : ( - - )} -
-
-

1y Change

- {isAuthenticated ? ( -

0 ? "text-orange-400" : - details.price_change_1y < 0 ? "text-accent" : - "text-foreground" - )}> - {details.price_change_1y > 0 ? '+' : ''}{details.price_change_1y.toFixed(0)}% -

- ) : ( - - )} -
-
-

3y Change

- {isAuthenticated ? ( -

0 ? "text-orange-400" : - details.price_change_3y < 0 ? "text-accent" : - "text-foreground" - )}> - {details.price_change_3y > 0 ? '+' : ''}{details.price_change_3y.toFixed(0)}% -

- ) : ( - - )} -
-
- - {/* Risk Assessment */} - {isAuthenticated && ( -
- -
-

Risk Assessment

-
- {getRiskBadge()} -
- )} -
- - {/* Right: Price Card */} -
-
- {isAuthenticated ? ( - <> -
- - ${details.pricing.min.toFixed(2)} - - /yr -
-

- Cheapest at {details.cheapest_registrar} -

- - - - {savings && savings.amount > 0.5 && ( -
-
- -

- Save ${savings.amount.toFixed(2)}/yr vs {savings.expensiveName} -

-
-
- )} - - ) : ( - <> - - - - - Sign in to View Prices - - - )} -
-
-
- - {/* Renewal Trap Warning */} - {isAuthenticated && renewalInfo?.isTrap && ( -
- -
-

Renewal Trap Detected

-

- The renewal price (${renewalInfo.renewal.toFixed(2)}) is {renewalInfo.ratio.toFixed(1)}x higher than the registration price (${renewalInfo.registration.toFixed(2)}). - Consider the total cost of ownership before registering. -

-
-
- )} - - {/* Price Chart */} -
-
-
-

Price History

- {isAuthenticated && !hasPriceHistory && ( - Pro - )} -
- {hasPriceHistory && ( -
- {(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => ( - - ))} -
- )} -
- -
- {!isAuthenticated ? ( -
-
-
- - Sign in to view price history - - Sign in → - -
-
- ) : !hasPriceHistory ? ( -
-
-
- - Price history requires Trader or Tycoon plan - - - Upgrade to Unlock - -
-
- ) : ( - <> - - - {filteredHistory.length > 0 && ( -
- - {new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - -
-
- High - ${chartStats.high.toFixed(2)} -
-
- Low - ${chartStats.low.toFixed(2)} -
-
- Today -
- )} - - )} -
-
- - {/* Domain Search */} -
-

- Check .{details.tld} Availability -

-
-
-
- setDomainSearch(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()} - placeholder="Enter domain name" - className="w-full px-4 py-3.5 pr-20 bg-background border border-border rounded-xl text-body text-foreground placeholder:text-foreground-subtle focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent transition-all" - /> - - .{tld} - -
- -
- - {domainResult && ( - setDomainResult(null)} - /> - )} -
-
- - {/* Registrar Comparison */} -
-

Compare Registrars

- - {isAuthenticated ? ( -
-
- - - - - - - - - - - - {details.registrars.map((registrar, i) => { - const hasRenewalTrap = registrar.renewal_price / registrar.registration_price > 1.5 - const isBestValue = i === 0 && !hasRenewalTrap - - return ( - - - - - - - - ) - })} - -
- Registrar - - Register - - Renew - - Transfer -
-
- {registrar.name} - {isBestValue && ( - - Best - - )} - {i === 0 && hasRenewalTrap && ( - - Cheap Start - - )} -
-
- - ${registrar.registration_price.toFixed(2)} - - - - ${registrar.renewal_price.toFixed(2)} - - {hasRenewalTrap && ( - - - - )} - - - ${registrar.transfer_price.toFixed(2)} - - - - Visit - - -
-
-
- ) : ( -
-
- {[1, 2, 3, 4].map(i => ( -
- - - - -
- ))} -
-
-
- -

Sign in to compare registrar prices

- - Join the Hunt - -
-
-
- )} -
- - {/* TLD Info */} -
-

About .{details.tld}

-
-
- -

Registry

-

{details.registry}

-
-
- -

Introduced

-

{details.introduced || 'Unknown'}

-
-
- -

Type

-

{details.type}

-
-
-
- - {/* Related TLDs */} - {relatedTlds.length > 0 && ( -
-

Similar Extensions

-
- {relatedTlds.map(related => ( - -

- .{related.tld} -

- {isAuthenticated ? ( -

- from ${related.price.toFixed(2)}/yr -

- ) : ( - - )} - - ))} -
-
- )} - - {/* CTA */} -
-

- Track .{details.tld} Domains -

-

- Monitor specific domains and get instant notifications when they become available. -

- - {isAuthenticated ? 'Go to Command Center' : 'Start Monitoring Free'} - - -
- -
-
- -
) } diff --git a/frontend/src/app/tld-pricing/page.tsx b/frontend/src/app/tld-pricing/page.tsx index e5a4f9c..02b92f8 100644 --- a/frontend/src/app/tld-pricing/page.tsx +++ b/frontend/src/app/tld-pricing/page.tsx @@ -1,572 +1,25 @@ 'use client' -import { useEffect, useState } from 'react' -import { Header } from '@/components/Header' -import { Footer } from '@/components/Footer' -import { PremiumTable } from '@/components/PremiumTable' -import { useStore } from '@/lib/store' -import { api } from '@/lib/api' -import { - TrendingUp, - ChevronRight, - ChevronLeft, - Search, - X, - Lock, - Globe, - AlertTriangle, - ArrowUpDown, -} from 'lucide-react' -import Link from 'next/link' -import clsx from 'clsx' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' -interface TldData { - tld: string - type: string - description: string - avg_registration_price: number - min_registration_price: number - max_registration_price: number - min_renewal_price: number - avg_renewal_price: number - registrar_count: number - trend: string - price_change_7d: number - price_change_1y: number - price_change_3y: number - risk_level: 'low' | 'medium' | 'high' - risk_reason: string - popularity_rank?: number -} - -interface TrendingTld { - tld: string - reason: string - price_change: number - current_price: number -} - -interface PaginationData { - total: number - limit: number - offset: number - has_more: boolean -} - -// Sparkline component - matching Command Center exactly -function Sparkline({ trend }: { trend: number }) { - const isPositive = trend > 0 - const isNeutral = trend === 0 +/** + * Redirect /tld-pricing to /intel + * This page is kept for backwards compatibility + */ +export default function TldPricingRedirect() { + const router = useRouter() + + useEffect(() => { + router.replace('/intel') + }, [router]) return ( -
- - {isNeutral ? ( - - ) : isPositive ? ( - - ) : ( - - )} - -
- ) -} - -export default function TldPricingPage() { - const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() - const [tlds, setTlds] = useState([]) - const [trending, setTrending] = useState([]) - const [loading, setLoading] = useState(true) - const [pagination, setPagination] = useState({ total: 0, limit: 50, offset: 0, has_more: false }) - - // Search & Sort state - const [searchQuery, setSearchQuery] = useState('') - const [debouncedSearch, setDebouncedSearch] = useState('') - const [sortBy, setSortBy] = useState('popularity') - const [page, setPage] = useState(0) - - // Debounce search - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearch(searchQuery) - }, 300) - return () => clearTimeout(timer) - }, [searchQuery]) - - useEffect(() => { - checkAuth() - loadTrending() - }, [checkAuth]) - - // Load TLDs with pagination, search, and sort - useEffect(() => { - loadTlds() - }, [debouncedSearch, sortBy, page]) - - const loadTlds = async () => { - setLoading(true) - try { - const data = await api.getTldOverview( - 50, - page * 50, - sortBy as 'popularity' | 'price_asc' | 'price_desc' | 'name', - debouncedSearch || undefined - ) - - setTlds(data?.tlds || []) - setPagination({ - total: data?.total || 0, - limit: 50, - offset: page * 50, - has_more: data?.has_more || false, - }) - } catch (error) { - console.error('Failed to load TLD data:', error) - setTlds([]) - } finally { - setLoading(false) - } - } - - const loadTrending = async () => { - try { - const data = await api.getTrendingTlds() - setTrending(data?.trending || []) - } catch (error) { - console.error('Failed to load trending:', error) - } - } - - // Risk badge - matching Command Center exactly - const getRiskBadge = (tld: TldData) => { - const level = tld.risk_level || 'low' - const reason = tld.risk_reason || 'Stable' - return ( - - - {reason} - - ) - } - - // Get renewal trap indicator - const getRenewalTrap = (tld: TldData) => { - if (!tld.min_renewal_price || !tld.min_registration_price) return null - const ratio = tld.min_renewal_price / tld.min_registration_price - if (ratio > 2) { - return ( - - - - ) - } - return null - } - - // Pagination calculations - const currentPage = Math.floor(pagination.offset / pagination.limit) + 1 - const totalPages = Math.ceil(pagination.total / pagination.limit) - - if (authLoading) { - return ( -
-
-
- ) - } - - return ( -
- {/* Background Effects - matching landing page */} -
-
-
-
-
- -
- -
-
- {/* Header */} -
-
- - Real-time Market Data -
-

- {pagination.total}+ TLDs. - True Costs. -

-

- Don't fall for promo prices. See renewal costs, spot traps, and track price trends across every extension. -

- - {/* Feature Pills */} -
-
- - Renewal Trap Detection -
-
-
- - - -
- Risk Levels -
-
- - 1y/3y Trends -
-
-
- - {/* Login Banner for non-authenticated users */} - {!isAuthenticated && ( -
-
-
-
- -
-
-

Stop overpaying. Know the true costs.

-

- Unlock renewal traps, 1y/3y trends, and risk analysis for {pagination.total}+ TLDs. -

-
-
- - Start Free - -
-
- )} - - {/* Trending Section */} - {trending.length > 0 && ( -
-

- - Moving Now -

-
- {trending.map((item) => ( - -
- .{item.tld} - 0 - ? "text-[#f97316] bg-[#f9731615]" - : "text-accent bg-accent-muted" - )}> - {item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}% - -
-

- {item.reason} -

-
- - ${item.current_price.toFixed(2)}/yr - - -
- - ))} -
-
- )} - - {/* Search & Sort Controls */} -
- {/* Search */} -
- - { - setSearchQuery(e.target.value) - setPage(0) - }} - className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl - text-body text-foreground placeholder:text-foreground-subtle - focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent - transition-all duration-300" - /> - {searchQuery && ( - - )} -
- - {/* Sort */} -
- - -
-
- - {/* TLD Table using PremiumTable - matching Command Center exactly */} - tld.tld} - loading={loading} - onRowClick={(tld) => { - if (isAuthenticated) { - window.location.href = `/tld-pricing/${tld.tld}` - } else { - window.location.href = `/login?redirect=/tld-pricing/${tld.tld}` - } - }} - emptyIcon={} - emptyTitle="No TLDs found" - emptyDescription={searchQuery ? `No TLDs matching "${searchQuery}"` : "Check back later for TLD data"} - columns={[ - { - key: 'tld', - header: 'TLD', - width: '100px', - render: (tld, idx) => ( -
- - .{tld.tld} - - {!isAuthenticated && idx === 0 && page === 0 && ( - Preview - )} -
- ), - }, - { - key: 'trend', - header: 'Trend', - width: '80px', - hideOnMobile: true, - render: (tld, idx) => { - const showData = isAuthenticated || (page === 0 && idx === 0) - if (!showData) { - return
- } - return - }, - }, - { - key: 'buy_price', - header: 'Buy (1y)', - align: 'right', - width: '100px', - render: (tld, idx) => { - const showData = isAuthenticated || (page === 0 && idx === 0) - if (!showData) { - return ••• - } - return ${tld.min_registration_price.toFixed(2)} - }, - }, - { - key: 'renew_price', - header: 'Renew (1y)', - align: 'right', - width: '120px', - render: (tld, idx) => { - const showData = isAuthenticated || (page === 0 && idx === 0) - if (!showData) { - return $XX.XX - } - return ( -
- - ${tld.min_renewal_price?.toFixed(2) || '—'} - - {getRenewalTrap(tld)} -
- ) - }, - }, - { - key: 'change_1y', - header: '1y Change', - align: 'right', - width: '100px', - hideOnMobile: true, - render: (tld, idx) => { - const showData = isAuthenticated || (page === 0 && idx === 0) - if (!showData) { - return +X% - } - const change = tld.price_change_1y || 0 - return ( - 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted" - )}> - {change > 0 ? '+' : ''}{change.toFixed(0)}% - - ) - }, - }, - { - key: 'change_3y', - header: '3y Change', - align: 'right', - width: '100px', - hideOnMobile: true, - render: (tld, idx) => { - const showData = isAuthenticated || (page === 0 && idx === 0) - if (!showData) { - return +X% - } - const change = tld.price_change_3y || 0 - return ( - 0 ? "text-orange-400" : change < 0 ? "text-accent" : "text-foreground-muted" - )}> - {change > 0 ? '+' : ''}{change.toFixed(0)}% - - ) - }, - }, - { - key: 'risk', - header: 'Risk', - align: 'center', - width: '130px', - render: (tld, idx) => { - const showData = isAuthenticated || (page === 0 && idx === 0) - if (!showData) { - return ( - - - Hidden - - ) - } - return getRiskBadge(tld) - }, - }, - { - key: 'actions', - header: '', - align: 'right', - width: '80px', - render: () => ( - - ), - }, - ]} - /> - - {/* Pagination */} - {!loading && pagination.total > pagination.limit && ( -
- - - Page {currentPage} of {totalPages} - - -
- )} - - {/* Stats */} - {!loading && ( -
-

- {searchQuery - ? `Found ${pagination.total} TLDs matching "${searchQuery}"` - : `${pagination.total} TLDs available` - } -

-
- )} -
-
- -