From 7c5669a2a29af7f6a1f23e6bc67d9006b1a5f768 Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Wed, 10 Dec 2025 13:50:21 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Complete=20TLD=20Pricing=20feature=20ac?= =?UTF-8?q?ross=20Public=20=E2=86=92=20Command=20=E2=86=92=20Admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PUBLIC PAGE (/tld-pricing): - Added renewal price column with trap warning (⚠️ when >2x) - Added 1y trend % column with color coding - Added risk level badges (🟢🟡🔴) - Blur effect for non-authenticated users on premium columns - All data from real backend API (not simulated) PUBLIC DETAIL PAGE (/tld-pricing/[tld]): - Already existed with full features - Shows price history chart, registrar comparison, domain check COMMAND CENTER (/command/pricing): - Full access to all data without blur - Category tabs: All, Tech, Geo, Budget, Premium - Sparklines for trend visualization - Risk-based sorting option COMMAND CENTER DETAIL (/command/pricing/[tld]): - NEW: Professional detail page with CommandCenterLayout - Price history chart with period selection (1M, 3M, 1Y, ALL) - Renewal trap warning banner - Registrar comparison table with trap indicators - Quick domain availability check - TLD info grid (type, registry, introduced, registrars) - Price alert toggle CLEANUP: - /intelligence → redirects to /tld-pricing (backwards compat) - Removed duplicate code All TLD Pricing data now flows from backend with: - Real renewal prices from registrar data - Calculated 1y/3y trends per TLD - Risk level and reason from backend --- .../src/app/command/pricing/[tld]/page.tsx | 678 ++++++++++++++++++ frontend/src/app/intelligence/page.tsx | 551 +------------- frontend/src/app/tld-pricing/page.tsx | 69 +- 3 files changed, 757 insertions(+), 541 deletions(-) create mode 100644 frontend/src/app/command/pricing/[tld]/page.tsx mode change 100755 => 100644 frontend/src/app/intelligence/page.tsx diff --git a/frontend/src/app/command/pricing/[tld]/page.tsx b/frontend/src/app/command/pricing/[tld]/page.tsx new file mode 100644 index 0000000..0e076f7 --- /dev/null +++ b/frontend/src/app/command/pricing/[tld]/page.tsx @@ -0,0 +1,678 @@ +'use client' + +import { useEffect, useState, useMemo, useRef } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { CommandCenterLayout } from '@/components/CommandCenterLayout' +import { PageContainer, StatCard } from '@/components/PremiumTable' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { + ArrowLeft, + TrendingUp, + TrendingDown, + Minus, + Calendar, + Globe, + Building, + ExternalLink, + Bell, + Search, + ChevronRight, + Sparkles, + Check, + X, + RefreshCw, + Clock, + Shield, + Zap, + AlertTriangle, + DollarSign, + BarChart3, +} 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 +} + +interface TldHistory { + tld: 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 + }> +} + +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=', +} + +type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL' + +// Premium Chart Component +function PriceChart({ + data, + chartStats, +}: { + data: Array<{ date: string; price: number }> + chartStats: { high: number; low: number; avg: number } +}) { + const [hoveredIndex, setHoveredIndex] = useState(null) + const containerRef = useRef(null) + + if (data.length === 0) { + return ( +
+ No price history available +
+ ) + } + + const minPrice = Math.min(...data.map(d => d.price)) + const maxPrice = Math.max(...data.map(d => d.price)) + const priceRange = maxPrice - minPrice || 1 + + const points = data.map((d, i) => ({ + x: (i / (data.length - 1)) * 100, + y: 100 - ((d.price - minPrice) / priceRange) * 80 - 10, + ...d, + })) + + const linePath = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ') + const areaPath = linePath + ` L${points[points.length - 1].x},100 L${points[0].x},100 Z` + + const isRising = data[data.length - 1].price > data[0].price + const strokeColor = isRising ? '#f97316' : '#00d4aa' + + return ( +
setHoveredIndex(null)} + > + { + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const x = ((e.clientX - rect.left) / rect.width) * 100 + const idx = Math.round((x / 100) * (points.length - 1)) + setHoveredIndex(Math.max(0, Math.min(idx, points.length - 1))) + }} + > + + + + + + + + + + {hoveredIndex !== null && points[hoveredIndex] && ( + + )} + + + {/* Tooltip */} + {hoveredIndex !== null && points[hoveredIndex] && ( +
+

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

+

{new Date(points[hoveredIndex].date).toLocaleDateString()}

+
+ )} + + {/* Y-axis labels */} +
+ ${maxPrice.toFixed(2)} + ${((maxPrice + minPrice) / 2).toFixed(2)} + ${minPrice.toFixed(2)} +
+
+ ) +} + +export default function CommandTldDetailPage() { + const params = useParams() + const router = useRouter() + const { subscription, fetchSubscription } = useStore() + const tld = params.tld as string + + const [details, setDetails] = useState(null) + const [history, setHistory] = useState(null) + 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) + const [alertEnabled, setAlertEnabled] = useState(false) + const [alertLoading, setAlertLoading] = useState(false) + + useEffect(() => { + fetchSubscription() + if (tld) { + loadData() + loadAlertStatus() + } + }, [tld, fetchSubscription]) + + const loadAlertStatus = async () => { + try { + const status = await api.getPriceAlertStatus(tld) + setAlertEnabled(status.has_alert && status.is_active) + } catch (err) { + // Ignore + } + } + + const handleToggleAlert = async () => { + setAlertLoading(true) + try { + const result = await api.togglePriceAlert(tld) + setAlertEnabled(result.is_active) + } catch (err) { + console.error('Failed to toggle alert:', err) + } finally { + setAlertLoading(false) + } + } + + const loadData = async () => { + try { + const [historyData, compareData] = await Promise.all([ + api.getTldHistory(tld, 365), + api.getTldCompare(tld), + ]) + + if (historyData && compareData) { + const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) => + a.registration_price - b.registration_price + ) + + 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', + }) + 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 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 + } + + // Calculate renewal trap info + const getRenewalInfo = () => { + 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, + } + } + + const renewalInfo = getRenewalInfo() + + const getTrendIcon = (trend: string) => { + switch (trend) { + case 'up': return + case 'down': return + default: return + } + } + + if (loading) { + return ( + + +
+ +
+
+
+ ) + } + + if (error || !details) { + return ( + + +
+
+ +
+

TLD Not Found

+

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

+ + + Back to TLD Pricing + +
+
+
+ ) + } + + return ( + + + {alertEnabled ? 'Alert On' : 'Set Alert'} + + } + > + + {/* Breadcrumb */} + + + {/* Stats Grid */} +
+ + + 0 ? '+' : ''}${history.price_change_7d.toFixed(1)}%` : '—'} + icon={BarChart3} + /> + +
+ + {/* Renewal Trap Warning */} + {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

+
+ {(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map((period) => ( + + ))} +
+
+ +
+ +
+ + {/* Chart Stats */} +
+
+

Period High

+

${chartStats.high.toFixed(2)}

+
+
+

Average

+

${chartStats.avg.toFixed(2)}

+
+
+

Period Low

+

${chartStats.low.toFixed(2)}

+
+
+
+ + {/* Registrar Comparison */} +
+

Registrar Comparison

+ +
+ + + + + + + + + + + + {details.registrars.map((registrar, idx) => ( + + + + + + + + ))} + +
RegistrarRegisterRenewTransfer
+
+ {registrar.name} + {idx === 0 && ( + Cheapest + )} +
+
+ + ${registrar.registration_price.toFixed(2)} + + +
+ ${registrar.renewal_price.toFixed(2)} + {registrar.renewal_price / registrar.registration_price > 2 && ( + + )} +
+
+ ${registrar.transfer_price.toFixed(2)} + + + Visit + + +
+
+
+ + {/* Quick Domain Check */} +
+

Quick Domain Check

+

+ Check if a domain is available with .{tld} +

+ +
+
+ setDomainSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleDomainCheck()} + placeholder={`example or example.${tld}`} + className="w-full h-11 px-4 bg-background border border-border/50 rounded-xl + text-sm text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:border-accent/50 transition-all" + /> +
+ +
+ + {/* Result */} + {domainResult && ( +
+
+ {domainResult.is_available ? ( + + ) : ( + + )} +
+

{domainResult.domain}

+

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

+
+
+ + {domainResult.is_available && ( + + Register at {details.cheapest_registrar} + + + )} +
+ )} +
+ + {/* TLD Info */} +
+

TLD Information

+ +
+
+
+ + Type +
+

{details.type}

+
+
+
+ + Registry +
+

{details.registry}

+
+
+
+ + Introduced +
+

{details.introduced || 'Unknown'}

+
+
+
+ + Registrars +
+

{details.registrars.length} tracked

+
+
+
+
+
+ ) +} + diff --git a/frontend/src/app/intelligence/page.tsx b/frontend/src/app/intelligence/page.tsx old mode 100755 new mode 100644 index 593c267..88cf19b --- a/frontend/src/app/intelligence/page.tsx +++ b/frontend/src/app/intelligence/page.tsx @@ -1,543 +1,26 @@ 'use client' -import { useEffect, useState } from 'react' -import { useStore } from '@/lib/store' -import { api } from '@/lib/api' -import { Header } from '@/components/Header' -import { Footer } from '@/components/Footer' -import { PremiumTable, Badge, StatCard } from '@/components/PremiumTable' -import { - Search, - TrendingUp, - TrendingDown, - Minus, - ChevronRight, - Globe, - ArrowUpDown, - DollarSign, - BarChart3, - RefreshCw, - X, - ArrowRight, - Lock, - Sparkles, - AlertTriangle, - Cpu, - MapPin, - Coins, - Crown, - Info, -} from 'lucide-react' -import clsx from 'clsx' -import Link from 'next/link' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' -interface TLDData { - tld: string - min_price: number - avg_price: number - max_price: number - min_renewal_price: number - avg_renewal_price: number - cheapest_registrar?: string - cheapest_registrar_url?: string - price_change_7d: number - price_change_1y: number - price_change_3y: number - risk_level: 'low' | 'medium' | 'high' - risk_reason: string - popularity_rank?: number - type?: string -} - -// Category definitions -const CATEGORIES = { - all: { label: 'All', icon: Globe, filter: () => true }, - tech: { label: 'Tech', icon: Cpu, filter: (tld: TLDData) => ['ai', 'io', 'app', 'dev', 'tech', 'code', 'cloud', 'data', 'api', 'software'].includes(tld.tld) }, - geo: { label: 'Geo', icon: MapPin, filter: (tld: TLDData) => ['ch', 'de', 'uk', 'us', 'fr', 'it', 'es', 'nl', 'at', 'eu', 'co', 'ca', 'au', 'nz', 'jp', 'cn', 'in', 'br', 'mx', 'nyc', 'london', 'paris', 'berlin', 'tokyo', 'swiss'].includes(tld.tld) }, - budget: { label: 'Budget', icon: Coins, filter: (tld: TLDData) => tld.min_price < 5 }, - premium: { label: 'Premium', icon: Crown, filter: (tld: TLDData) => tld.min_price >= 50 }, -} - -type CategoryKey = keyof typeof CATEGORIES - -// Risk level now comes from backend -function getRiskInfo(tld: TLDData): { level: 'low' | 'medium' | 'high', reason: string } { - return { - level: tld.risk_level || 'low', - reason: tld.risk_reason || 'Stable' - } -} - -// Sparkline component -function Sparkline({ trend, className }: { trend: number, className?: string }) { - const isPositive = trend > 0 - const isNeutral = trend === 0 +/** + * Redirect /intelligence to /tld-pricing + * This page is kept for backwards compatibility + */ +export default function IntelligenceRedirect() { + const router = useRouter() + + useEffect(() => { + router.replace('/tld-pricing') + }, [router]) return ( -
- - {isNeutral ? ( - - ) : isPositive ? ( - - ) : ( - - )} - +
+
+
+

Redirecting to TLD Pricing...

+
) } -export default function TLDPricingPublicPage() { - const { isAuthenticated, checkAuth } = useStore() - - const [tldData, setTldData] = useState([]) - const [loading, setLoading] = useState(true) - const [refreshing, setRefreshing] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [sortBy, setSortBy] = useState<'popularity' | 'price_asc' | 'price_desc' | 'change'>('popularity') - const [category, setCategory] = useState('all') - const [page, setPage] = useState(0) - const [total, setTotal] = useState(0) - - // Number of visible rows for non-authenticated users before blur - const FREE_VISIBLE_ROWS = 5 - - useEffect(() => { - checkAuth() - }, [checkAuth]) - - useEffect(() => { - loadTLDData() - }, [page, sortBy]) - - const loadTLDData = async () => { - setLoading(true) - try { - const response = await api.getTldOverview( - 50, - page * 50, - sortBy, - ) - // Map API response to component interface - const mapped: TLDData[] = (response.tlds || []).map((tld) => ({ - tld: tld.tld, - min_price: tld.min_registration_price, - avg_price: tld.avg_registration_price, - max_price: tld.max_registration_price, - min_renewal_price: tld.min_renewal_price, - avg_renewal_price: tld.avg_renewal_price, - price_change_7d: tld.price_change_7d, - price_change_1y: tld.price_change_1y, - price_change_3y: tld.price_change_3y, - risk_level: tld.risk_level, - risk_reason: tld.risk_reason, - popularity_rank: tld.popularity_rank, - type: tld.type, - })) - setTldData(mapped) - setTotal(response.total || 0) - } catch (error) { - console.error('Failed to load TLD data:', error) - } finally { - setLoading(false) - } - } - - const handleRefresh = async () => { - setRefreshing(true) - await loadTLDData() - setRefreshing(false) - } - - // Apply filters - const filteredData = tldData - .filter(tld => CATEGORIES[category].filter(tld)) - .filter(tld => tld.tld.toLowerCase().includes(searchQuery.toLowerCase())) - - // Calculate stats - const lowestPrice = tldData.length > 0 - ? tldData.reduce((min, tld) => Math.min(min, tld.min_price), Infinity) - : 0.99 - const hottestTld = tldData.find(tld => (tld.price_change_7d || 0) > 5)?.tld || 'ai' - const trapCount = tldData.filter(tld => tld.risk_level === 'high' || tld.risk_level === 'medium').length - - const getRiskBadge = (tld: TLDData, blurred: boolean) => { - const { level, reason } = getRiskInfo(tld) - if (blurred) { - return ( - - 🟢 Hidden - - ) - } - return ( - - {level === 'high' && '🔴'} - {level === 'medium' && '🟡'} - {level === 'low' && '🟢'} - {reason} - - ) - } - - const getRenewalTrap = (tld: TLDData) => { - const ratio = tld.min_renewal_price / tld.min_price - if (ratio > 2) { - return ( - - - - ) - } - return null - } - - return ( - <> -
-
- {/* Hero Header */} -
-
-
-
- -
-
- - Real-time Market Data -
- -

- TLD Pricing - & Trends -

- -

- Track prices, renewal traps, and trends across {total > 0 ? total.toLocaleString() : '800+'} TLDs. - Make informed decisions with real market data. -

- - {/* Stats Cards */} -
-
- -
{total > 0 ? total.toLocaleString() : '—'}
-
TLDs Tracked
-
-
- -
{total > 0 ? `$${lowestPrice.toFixed(2)}` : '—'}
-
Lowest Price
-
-
- -
.{hottestTld}
-
Hottest TLD
-
-
- -
{trapCount}
-
Renewal Traps
-
-
-
-
- - {/* Main Content */} -
-
- {/* Category Tabs */} -
- {(Object.keys(CATEGORIES) as CategoryKey[]).map((key) => { - const cat = CATEGORIES[key] - const Icon = cat.icon - return ( - - ) - })} -
- - {/* Filters */} -
-
- - setSearchQuery(e.target.value)} - placeholder="Search TLDs (e.g. com, io, dev)..." - className="w-full h-11 pl-11 pr-10 bg-background-secondary/50 border border-border/50 rounded-xl - text-sm text-foreground placeholder:text-foreground-subtle - focus:outline-none focus:border-accent/50 transition-all" - /> - {searchQuery && ( - - )} -
-
- - -
- -
- - {/* Legend */} -
-
- - Renewal trap: Renewal >2x registration -
-
- - Risk levels: 🟢 Low, 🟡 Medium, 🔴 High -
-
- - {/* TLD Table */} -
-
-
- - - - - - - - - - - - - - {loading ? ( - Array.from({ length: 10 }).map((_, i) => ( - - - - )) - ) : filteredData.length === 0 ? ( - - - - ) : ( - filteredData.map((tld, index) => { - const isBlurred = !isAuthenticated && index >= FREE_VISIBLE_ROWS - const change1y = tld.price_change_1y || 0 - - return ( - !isBlurred && (window.location.href = `/tld-pricing/${tld.tld}`)} - > - - - - - - - - - ) - }) - )} - -
TLDTrendBuy (1y)Renew (1y)1y ChangeRisk
-
-
- -

No TLDs found

-

{searchQuery ? `No results for "${searchQuery}"` : 'Check back later'}

-
- - .{tld.tld} - - - {isBlurred ? ( -
- ) : ( - - )} -
- ${tld.min_price.toFixed(2)} - - {isBlurred ? ( - $XX.XX - ) : ( -
- - ${tld.min_renewal_price.toFixed(2)} - - {getRenewalTrap(tld)} -
- )} -
- {isBlurred ? ( - +XX% - ) : ( - 0 ? "text-orange-400" : change1y < 0 ? "text-accent" : "text-foreground-muted" - )}> - {change1y > 0 ? '+' : ''}{change1y.toFixed(0)}% - - )} - - {getRiskBadge(tld, isBlurred)} - - {isBlurred ? ( - - ) : ( - - )} -
-
-
- - {/* Blur overlay CTA for non-authenticated */} - {!isAuthenticated && filteredData.length > FREE_VISIBLE_ROWS && ( -
-
-

- Stop overpaying. Unlock renewal prices, trends & risk analysis for {total}+ TLDs. -

- - - Start Free - - -
-
- )} -
- - {/* Pagination - only for authenticated */} - {isAuthenticated && total > 50 && ( -
- - - Page {page + 1} of {Math.ceil(total / 50)} - - -
- )} - - {/* Login CTA Banner */} - {!isAuthenticated && ( -
-

- 🔍 See the Full Picture -

-

- Unlock renewal traps, 3-year trends, price alerts, and our risk analysis across 800+ TLDs. - Make data-driven domain decisions. -

-
- - - Create Free Account - - - Sign In - -
-
- )} -
-
-
-