diff --git a/backend/app/api/tld_prices.py b/backend/app/api/tld_prices.py index db2d181..76004de 100644 --- a/backend/app/api/tld_prices.py +++ b/backend/app/api/tld_prices.py @@ -326,16 +326,31 @@ def get_max_price(tld_data: dict) -> float: return max(r["register"] for r in tld_data["registrars"].values()) +# Top TLDs by popularity (based on actual domain registration volumes) +TOP_TLDS_BY_POPULARITY = [ + "com", "net", "org", "de", "uk", "cn", "ru", "nl", "br", "au", + "io", "co", "ai", "app", "dev", "xyz", "online", "site", "tech", "store", + "info", "biz", "me", "tv", "cc", "eu", "fr", "it", "es", "pl", + "ch", "at", "be", "se", "no", "dk", "fi", "ie", "nz", "in", +] + + @router.get("/overview") async def get_tld_overview( db: Database, - limit: int = Query(50, ge=1, le=100), + limit: int = Query(25, ge=1, le=100), + offset: int = Query(0, ge=0), sort_by: str = Query("popularity", enum=["popularity", "price_asc", "price_desc", "name"]), + search: str = Query(None, description="Search TLDs by name"), source: str = Query("auto", enum=["auto", "db", "static"]), ): - """Get overview of TLDs with current pricing. + """Get overview of TLDs with current pricing, pagination, and search. Args: + limit: Number of results per page (default 25) + offset: Skip N results for pagination + search: Filter TLDs by name (e.g., "com", "io") + sort_by: Sort order - popularity (default), price_asc, price_desc, name source: Data source - "auto" (DB first, fallback to static), "db" (only DB), "static" (only static) """ tld_list = [] @@ -359,6 +374,7 @@ async def get_tld_overview( "max_registration_price": max(prices), "registrar_count": len(data["registrars"]), "trend": TLD_DATA.get(tld, {}).get("trend", "stable"), + "popularity_rank": TOP_TLDS_BY_POPULARITY.index(tld) if tld in TOP_TLDS_BY_POPULARITY else 999, }) # Use static data as fallback or if requested @@ -374,19 +390,36 @@ async def get_tld_overview( "max_registration_price": get_max_price(data), "registrar_count": len(data["registrars"]), "trend": data["trend"], + "popularity_rank": TOP_TLDS_BY_POPULARITY.index(tld) if tld in TOP_TLDS_BY_POPULARITY else 999, }) + # Apply search filter + if search: + search_lower = search.lower().lstrip(".") + tld_list = [t for t in tld_list if search_lower in t["tld"].lower()] + + # Store total before pagination + total = len(tld_list) + # Sort - if sort_by == "price_asc": + if sort_by == "popularity": + tld_list.sort(key=lambda x: (x["popularity_rank"], x["tld"])) + elif sort_by == "price_asc": tld_list.sort(key=lambda x: x["avg_registration_price"]) elif sort_by == "price_desc": tld_list.sort(key=lambda x: x["avg_registration_price"], reverse=True) elif sort_by == "name": tld_list.sort(key=lambda x: x["tld"]) + # Apply pagination + paginated = tld_list[offset:offset + limit] + return { - "tlds": tld_list[:limit], - "total": len(tld_list), + "tlds": paginated, + "total": total, + "limit": limit, + "offset": offset, + "has_more": offset + limit < total, "source": data_source, } diff --git a/frontend/src/app/tld-pricing/page.tsx b/frontend/src/app/tld-pricing/page.tsx index 288e31c..fb7a219 100644 --- a/frontend/src/app/tld-pricing/page.tsx +++ b/frontend/src/app/tld-pricing/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, useCallback } from 'react' import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' import { useStore } from '@/lib/store' @@ -16,6 +16,9 @@ import { ChevronsUpDown, Lock, ChevronRight, + ChevronLeft, + Search, + X, } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' @@ -29,6 +32,7 @@ interface TldData { max_registration_price: number registrar_count: number trend: string + popularity_rank?: number } interface TrendingTld { @@ -38,17 +42,17 @@ interface TrendingTld { current_price: number } -interface TldHistoryData { - history: Array<{ - date: string - price: number - }> +interface PaginationData { + total: number + limit: number + offset: number + has_more: boolean } -type SortField = 'tld' | 'avg_registration_price' | 'min_registration_price' | 'registrar_count' +type SortField = 'popularity' | 'tld' | 'avg_registration_price' | 'min_registration_price' type SortDirection = 'asc' | 'desc' -// Mini sparkline chart component with real data - Enhanced version +// Mini sparkline chart component function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boolean }) { const [historyData, setHistoryData] = useState([]) const [loading, setLoading] = useState(true) @@ -56,6 +60,8 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo useEffect(() => { if (isAuthenticated) { loadHistory() + } else { + setLoading(false) } }, [tld, isAuthenticated]) @@ -64,9 +70,9 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo const data = await api.getTldHistory(tld, 365) const history = data.history || [] const sampledData = history - .filter((_, i) => i % Math.floor(history.length / 12) === 0) + .filter((_: unknown, i: number) => i % Math.max(1, Math.floor(history.length / 12)) === 0) .slice(0, 12) - .map(h => h.price) + .map((h: { price: number }) => h.price) setHistoryData(sampledData.length > 0 ? sampledData : []) } catch (error) { @@ -77,28 +83,34 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo } } - if (!isAuthenticated || loading || historyData.length === 0) { + if (!isAuthenticated) { return (
- Sign in + 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] - // Create line points const linePoints = historyData.map((value, i) => { const x = (i / (historyData.length - 1)) * 100 - const y = 100 - ((value - min) / range) * 80 - 10 // Add padding + const y = 100 - ((value - min) / range) * 80 - 10 return `${x},${y}` }).join(' ') - // Create area path for gradient fill const areaPath = historyData.map((value, i) => { const x = (i / (historyData.length - 1)) * 100 const y = 100 - ((value - min) / range) * 80 - 10 @@ -111,57 +123,19 @@ function MiniChart({ tld, isAuthenticated }: { tld: string, isAuthenticated: boo - - + + - - {/* Area fill */} - - - {/* Line */} + - - {/* Dots for each data point */} - {historyData.map((value, i) => { - const x = (i / (historyData.length - 1)) * 100 - const y = 100 - ((value - min) / range) * 80 - 10 - return ( - - ) - })} ) } @@ -175,47 +149,74 @@ function SortIcon({ field, currentField, direction }: { field: SortField, curren : } -function ShimmerBlock({ className }: { className?: string }) { - return ( -
-
-
- ) -} - export default function TldPricingPage() { const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() const [tlds, setTlds] = useState([]) const [trending, setTrending] = useState([]) const [loading, setLoading] = useState(true) - const [sortField, setSortField] = useState('tld') + const [pagination, setPagination] = useState({ total: 0, limit: 25, offset: 0, has_more: false }) + + // Search & Sort state + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const [sortField, setSortField] = useState('popularity') const [sortDirection, setSortDirection] = useState('asc') + // Debounce search + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchQuery) + }, 300) + return () => clearTimeout(timer) + }, [searchQuery]) + useEffect(() => { checkAuth() - loadData() + loadTrending() }, [checkAuth]) - const loadData = async () => { + // Load TLDs with pagination, search, and sort + useEffect(() => { + loadTlds() + }, [debouncedSearch, sortField, sortDirection, pagination.offset]) + + const loadTlds = async () => { + setLoading(true) try { - const [overviewData, trendingData] = await Promise.all([ - api.getTldOverview(100), - api.getTrendingTlds(), - ]) - setTlds(overviewData?.tlds || []) - setTrending(trendingData?.trending || []) + 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, + sortBy, + debouncedSearch || undefined + ) + + setTlds(data?.tlds || []) + setPagination(prev => ({ + ...prev, + total: data?.total || 0, + has_more: data?.has_more || false, + })) } catch (error) { console.error('Failed to load TLD data:', error) setTlds([]) - setTrending([]) } finally { setLoading(false) } } + const loadTrending = async () => { + try { + const data = await api.getTrendingTlds() + setTrending(data?.trending || []) + } catch (error) { + console.error('Failed to load trending:', error) + } + } + const handleSort = (field: SortField) => { if (sortField === field) { setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') @@ -223,22 +224,15 @@ export default function TldPricingPage() { setSortField(field) setSortDirection('asc') } + // Reset to first page on sort change + setPagination(prev => ({ ...prev, offset: 0 })) } - const sortedTlds = useMemo(() => { - const sorted = [...tlds].sort((a, b) => { - let aVal: number | string = a[sortField] - let bVal: number | string = b[sortField] - - if (typeof aVal === 'string') aVal = aVal.toLowerCase() - if (typeof bVal === 'string') bVal = bVal.toLowerCase() - - if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1 - if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1 - return 0 - }) - return sorted - }, [tlds, sortField, sortDirection]) + 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) { @@ -251,7 +245,11 @@ export default function TldPricingPage() { } } - if (loading || authLoading) { + // Pagination calculations + const currentPage = Math.floor(pagination.offset / pagination.limit) + 1 + const totalPages = Math.ceil(pagination.total / pagination.limit) + + if (authLoading) { return (
@@ -280,7 +278,7 @@ export default function TldPricingPage() { Domain Extension Pricing

- Track price trends across all major TLDs. Compare prices and monitor trends over time. + Track price trends across {pagination.total}+ TLDs. Compare prices and monitor trends over time.

@@ -294,7 +292,7 @@ export default function TldPricingPage() {

Unlock Full TLD Data

- Sign in to see detailed pricing and trends. + Sign in to see detailed pricing, charts, and trends.

@@ -324,30 +322,22 @@ export default function TldPricingPage() { >
.{item.tld} - {isAuthenticated ? ( - 0 - ? "text-[#f97316] bg-[#f9731615]" - : "text-accent bg-accent-muted" - )}> - {item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}% - - ) : ( - - )} + 0 + ? "text-[#f97316] bg-[#f9731615]" + : "text-accent bg-accent-muted" + )}> + {item.price_change > 0 ? '+' : ''}{item.price_change.toFixed(1)}% +

- {isAuthenticated ? item.reason : 'Sign in to view trend details'} + {item.reason}

- {isAuthenticated ? ( - - ${item.current_price.toFixed(2)}/yr - - ) : ( - - )} + + ${item.current_price.toFixed(2)}/yr +
@@ -356,12 +346,52 @@ export default function TldPricingPage() {
)} + {/* Search Bar */} +
+
+ + { + setSearchQuery(e.target.value) + setPagination(prev => ({ ...prev, offset: 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 */}
+ - @@ -415,76 +432,181 @@ export default function TldPricingPage() { - {sortedTlds.map((tld, idx) => ( - - - - - - - - - + + + + + + + + + + )) + ) : tlds.length === 0 ? ( + + - ))} + ) : ( + tlds.map((tld, idx) => ( + + + + + + + + + + + )) + )}
+ + - - Description - + Type - - 12-Month Trend - + 12-Month Trend - - Trend
- - .{tld.tld} - - - - {tld.description} - - - - - {isAuthenticated ? ( - - ${tld.avg_registration_price.toFixed(2)} - - ) : ( - ••• - )} - - {isAuthenticated ? ( - - ${tld.min_registration_price.toFixed(2)} - - ) : ( - ••• - )} - - {isAuthenticated ? ( - - {tld.registrar_count} - - ) : ( - - )} - - {isAuthenticated ? getTrendIcon(tld.trend) : } - - - Details - - + {loading ? ( + // Loading skeleton + Array.from({ length: 10 }).map((_, idx) => ( +
+ {searchQuery ? `No TLDs found matching "${searchQuery}"` : 'No TLDs found'}
+ + {pagination.offset + idx + 1} + + + + .{tld.tld} + + + + {tld.type} + + + + + {isAuthenticated ? ( + + ${tld.avg_registration_price.toFixed(2)} + + ) : ( + ••• + )} + + {isAuthenticated ? ( + + ${tld.min_registration_price.toFixed(2)} + + ) : ( + ••• + )} + + {isAuthenticated ? getTrendIcon(tld.trend) : } + + + Details + + +
+ + {/* 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 */} + +
+
+ )}
{/* Stats */} -
-

- Showing {sortedTlds.length} TLDs -

-
+ {!loading && ( +
+

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

+
+ )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 48a0f71..d021445 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -225,7 +225,20 @@ class ApiClient { } // TLD Pricing - async getTldOverview(limit = 20) { + async getTldOverview( + limit = 25, + offset = 0, + sortBy: 'popularity' | 'price_asc' | 'price_desc' | 'name' = 'popularity', + search?: string + ) { + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + sort_by: sortBy, + }) + if (search) { + params.append('search', search) + } return this.request<{ tlds: Array<{ tld: string @@ -236,9 +249,14 @@ class ApiClient { max_registration_price: number registrar_count: number trend: string + popularity_rank?: number }> total: number - }>(`/tld-prices/overview?limit=${limit}`) + limit: number + offset: number + has_more: boolean + source: string + }>(`/tld-prices/overview?${params.toString()}`) } async getTldHistory(tld: string, days = 90) {