From 7ffaa8265cdb2c7e1208439232a33fe1670771c9 Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Wed, 10 Dec 2025 15:40:34 +0100 Subject: [PATCH] refactor: Rebuild TLD Pricing public page with PremiumTable PUBLIC TLD PRICING PAGE: - Replaced manual HTML table with PremiumTable component - Now matches Command Center table exactly - Same columns: TLD, Trend, Buy, Renew, 1y, 3y, Risk - Consistent row hover effects and styling - Simplified sparkline component - Preview row for non-authenticated users (first row unblurred) - Blurred data for other rows when not logged in COMMAND TLD PRICING PAGE: - Removed Bell icon and alert functionality from actions column - Cleaned up unused imports (Bell, Link) - Actions column now only shows ChevronRight arrow CONSISTENCY ACHIEVED: - Both tables use identical column structure - Same renewal trap indicators - Same risk level dots (no emojis) - Same trend sparklines - Same price formatting --- frontend/src/app/command/pricing/page.tsx | 16 +- frontend/src/app/tld-pricing/page.tsx | 706 +++++++++------------- 2 files changed, 273 insertions(+), 449 deletions(-) diff --git a/frontend/src/app/command/pricing/page.tsx b/frontend/src/app/command/pricing/page.tsx index f0524ae..e1b20e9 100755 --- a/frontend/src/app/command/pricing/page.tsx +++ b/frontend/src/app/command/pricing/page.tsx @@ -16,7 +16,6 @@ import { DollarSign, BarChart3, RefreshCw, - Bell, X, AlertTriangle, Shield, @@ -28,7 +27,6 @@ import { Info, } from 'lucide-react' import clsx from 'clsx' -import Link from 'next/link' interface TLDData { tld: string @@ -440,18 +438,8 @@ export default function TLDPricingPage() { header: '', align: 'right', width: '80px', - render: (tld) => ( -
- e.stopPropagation()} - title="View details" - > - - - -
+ render: () => ( + ), }, ]} diff --git a/frontend/src/app/tld-pricing/page.tsx b/frontend/src/app/tld-pricing/page.tsx index 7d90b4a..2185ed8 100644 --- a/frontend/src/app/tld-pricing/page.tsx +++ b/frontend/src/app/tld-pricing/page.tsx @@ -1,24 +1,23 @@ 'use client' -import { useEffect, useState, useMemo, useCallback } from 'react' +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, TrendingDown, Minus, - ArrowRight, - BarChart3, - ChevronUp, - ChevronDown, - ChevronsUpDown, - Lock, ChevronRight, ChevronLeft, Search, X, + Lock, + Globe, + AlertTriangle, + ArrowUpDown, } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' @@ -56,106 +55,36 @@ interface PaginationData { has_more: boolean } -type SortField = 'popularity' | 'tld' | 'avg_registration_price' | 'min_registration_price' -type SortDirection = 'asc' | 'desc' - -// Mini sparkline chart component -function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boolean }) { - const [historyData, setHistoryData] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - if (isAuthenticated) { - loadHistory() - } else { - setLoading(false) - } - }, [tld, isAuthenticated]) - - const loadHistory = async () => { - try { - const data = await api.getTldHistory(tld, 365) - const history = data.history || [] - const sampledData = history - .filter((_: unknown, i: number) => i % Math.max(1, Math.floor(history.length / 12)) === 0) - .slice(0, 12) - .map((h: { price: number }) => h.price) - - setHistoryData(sampledData.length > 0 ? sampledData : []) - } catch (error) { - console.error('Failed to load history:', error) - setHistoryData([]) - } finally { - setLoading(false) - } - } - - if (!isAuthenticated) { - return ( -
- - Sign in -
- ) - } - - if (loading) { - return
- } - - if (historyData.length === 0) { - return
No data
- } - - const min = Math.min(...historyData) - const max = Math.max(...historyData) - const range = max - min || 1 - const isIncreasing = historyData[historyData.length - 1] > historyData[0] - - const linePoints = historyData.map((value, i) => { - const x = (i / (historyData.length - 1)) * 100 - const y = 100 - ((value - min) / range) * 80 - 10 - return `${x},${y}` +// Sparkline component +function Sparkline({ trend }: { trend: number }) { + const isPositive = trend > 0 + const isNegative = trend < 0 + + // Generate simple sparkline points + const points = Array.from({ length: 8 }, (_, i) => { + const baseY = 50 + const variance = isPositive ? -trend * 3 : isNegative ? -trend * 3 : 5 + return `${i * 14},${baseY + (Math.random() * variance - variance / 2) * (i / 7)}` }).join(' ') - - const areaPath = historyData.map((value, i) => { - const x = (i / (historyData.length - 1)) * 100 - const y = 100 - ((value - min) / range) * 80 - 10 - return i === 0 ? `M${x},${y}` : `L${x},${y}` - }).join(' ') + ' L100,100 L0,100 Z' - - const gradientId = `gradient-${tld}` - + return ( - - - - - - - - - - +
+ + + +
) } -function SortIcon({ field, currentField, direction }: { field: SortField, currentField: SortField, direction: SortDirection }) { - if (field !== currentField) { - return - } - return direction === 'asc' - ? - : -} - export default function TldPricingPage() { const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() const [tlds, setTlds] = useState([]) @@ -166,8 +95,8 @@ export default function TldPricingPage() { // Search & Sort state const [searchQuery, setSearchQuery] = useState('') const [debouncedSearch, setDebouncedSearch] = useState('') - const [sortField, setSortField] = useState('popularity') - const [sortDirection, setSortDirection] = useState('asc') + const [sortBy, setSortBy] = useState('popularity') + const [page, setPage] = useState(0) // Debounce search useEffect(() => { @@ -185,28 +114,25 @@ export default function TldPricingPage() { // Load TLDs with pagination, search, and sort useEffect(() => { loadTlds() - }, [debouncedSearch, sortField, sortDirection, pagination.offset]) + }, [debouncedSearch, sortBy, page]) const loadTlds = async () => { setLoading(true) try { - const sortBy = sortField === 'tld' ? 'name' : sortField === 'popularity' ? 'popularity' : - sortField === 'avg_registration_price' ? (sortDirection === 'asc' ? 'price_asc' : 'price_desc') : - (sortDirection === 'asc' ? 'price_asc' : 'price_desc') - const data = await api.getTldOverview( - pagination.limit, - pagination.offset, + 50, + page * 50, sortBy, debouncedSearch || undefined ) setTlds(data?.tlds || []) - setPagination(prev => ({ - ...prev, + 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([]) @@ -224,32 +150,18 @@ export default function TldPricingPage() { } } - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') - } else { - setSortField(field) - setSortDirection('asc') - } - // Reset to first page on sort change - setPagination(prev => ({ ...prev, offset: 0 })) - } - - const handlePageChange = (newOffset: number) => { - setPagination(prev => ({ ...prev, offset: newOffset })) - // Scroll to top of table - window.scrollTo({ top: 300, behavior: 'smooth' }) - } - - const getTrendIcon = (trend: string) => { - switch (trend) { - case 'up': - return - case 'down': - return - default: - return + // 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 @@ -300,7 +212,7 @@ export default function TldPricingPage() { {/* Feature Pills */}
- ⚠️ + Renewal Trap Detection
@@ -325,21 +237,21 @@ export default function TldPricingPage() {
-
-
+
+

Stop overpaying. Know the true costs.

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

+

+
-
- + > Start Free - +
)} @@ -384,9 +296,10 @@ export default function TldPricingPage() { )} - {/* Search Bar */} -
-
+ {/* Search & Sort Controls */} +
+ {/* Search */} +
{ setSearchQuery(e.target.value) - setPagination(prev => ({ ...prev, offset: 0 })) + 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 @@ -405,7 +318,7 @@ export default function TldPricingPage() { )}
-
- - {/* TLD Table */} -
-
- - - - - - - - - - - - - - - - - {loading ? ( - // Loading skeleton - Array.from({ length: 10 }).map((_, idx) => ( - - - - - - - - - - - - - )) - ) : tlds.length === 0 ? ( - - - - ) : ( - tlds.map((tld, idx) => { - // Show full data for authenticated users OR for the first row (idx 0 on first page) - // This lets visitors see how good the data is for .com before signing up - const showFullData = isAuthenticated || (pagination.offset === 0 && idx === 0) - - return ( - - - - - - - - - - - - - ) - }) - )} - -
- - - - - Type - - 12-Month Chart - - - - - - Renew - - 1y Trend - - Risk -
- {searchQuery ? `No TLDs found matching "${searchQuery}"` : 'No TLDs found'} -
- - {pagination.offset + idx + 1} - - - - .{tld.tld} - - {!isAuthenticated && idx === 0 && pagination.offset === 0 && ( - Preview - )} - - - {tld.type} - - - - - {showFullData ? ( - - ${tld.avg_registration_price.toFixed(2)} - - ) : ( - ••• - )} - - {showFullData ? ( - - ${tld.min_registration_price.toFixed(2)} - - ) : ( - ••• - )} - - {showFullData ? ( -
- - ${tld.min_renewal_price?.toFixed(2) || '—'} - - {tld.min_renewal_price && tld.min_renewal_price / tld.min_registration_price > 2 && ( - ⚠️ - )} -
- ) : ( - $XX.XX - )} -
- {showFullData ? ( - 0 ? "text-[#f97316]" : - (tld.price_change_1y || 0) < 0 ? "text-accent" : "text-foreground-muted" - )}> - {(tld.price_change_1y || 0) > 0 ? '+' : ''}{(tld.price_change_1y || 0).toFixed(0)}% - - ) : ( - +X% - )} - - {showFullData ? ( - - - - ) : ( - - )} - - - Details - - -
+ + {/* Sort */} +
+ +
- - {/* Pagination */} - {!loading && pagination.total > pagination.limit && ( -
-

- Showing {pagination.offset + 1}-{Math.min(pagination.offset + pagination.limit, pagination.total)} of {pagination.total} TLDs -

- -
- {/* Previous Button */} - - - {/* Page Numbers */} -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - let pageNum: number - if (totalPages <= 5) { - pageNum = i + 1 - } else if (currentPage <= 3) { - pageNum = i + 1 - } else if (currentPage >= totalPages - 2) { - pageNum = totalPages - 4 + i - } else { - pageNum = currentPage - 2 + i - } - - return ( - - ) - })} -
- - {/* Mobile Page Indicator */} - - Page {currentPage} of {totalPages} - - - {/* Next Button */} - -
-
- )}
+ {/* TLD Table using PremiumTable */} + 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: '80px', + hideOnMobile: true, + render: (tld, idx) => { + const showData = isAuthenticated || (page === 0 && idx === 0) + if (!showData) { + return + } + return ( +
+ +
+ ) + }, + }, + { + key: 'action', + header: '', + align: 'right', + width: '40px', + render: () => ( + + ), + }, + ]} + /> + + {/* Pagination */} + {!loading && pagination.total > pagination.limit && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} + {/* Stats */} {!loading && (

{searchQuery ? `Found ${pagination.total} TLDs matching "${searchQuery}"` - : `${pagination.total} TLDs available • Sorted by ${sortField === 'popularity' ? 'popularity' : sortField === 'tld' ? 'name' : 'price'}` + : `${pagination.total} TLDs available` }