From badd5b835fd27c64d38f4ba18d29a1d3db79b276 Mon Sep 17 00:00:00 2001 From: "yves.gugger" Date: Mon, 8 Dec 2025 10:21:42 +0100 Subject: [PATCH] feat: Professional TLD detail page v2 Major improvements: - Elegant thin-line SVG chart with smooth bezier curves - Gradient area fill with subtle glow effect - Hover tooltips with price/date on chart - Shimmer effects for unauthenticated users (no data visible) - 'Enable Price Alert' toggle for authenticated users - Domain search results styled like landing page - Result card shows availability, registrar, expiration - Refined typography with uppercase labels - Better spacing and visual hierarchy - Registrar table hidden for non-authenticated users - Quick stats hidden for non-authenticated users - Related TLDs show shimmer for prices when logged out --- frontend/src/app/tld-pricing/[tld]/page.tsx | 1079 +++++++++++-------- 1 file changed, 639 insertions(+), 440 deletions(-) diff --git a/frontend/src/app/tld-pricing/[tld]/page.tsx b/frontend/src/app/tld-pricing/[tld]/page.tsx index 54b20cb..0bf742f 100644 --- a/frontend/src/app/tld-pricing/[tld]/page.tsx +++ b/frontend/src/app/tld-pricing/[tld]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, useRef } from 'react' import { useParams, useRouter } from 'next/navigation' import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' @@ -19,14 +19,13 @@ import { Search, ChevronRight, Sparkles, - Shield, - Clock, - Users, - ArrowUpRight, - ArrowDownRight, - Info, Check, X, + Lock, + RefreshCw, + Clock, + Shield, + Zap, } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' @@ -66,7 +65,16 @@ interface TldHistory { }> } -// Registrar URLs for affiliate/direct links +interface DomainCheckResult { + domain: string + is_available: boolean + status: string + registrar?: string + creation_date?: string + expiration_date?: string +} + +// Registrar URLs const REGISTRAR_URLS: Record = { 'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=', 'Porkbun': 'https://porkbun.com/checkout/search?q=', @@ -76,27 +84,302 @@ const REGISTRAR_URLS: Record = { 'porkbun': 'https://porkbun.com/checkout/search?q=', } -// Related TLDs mapping +// Related TLDs const RELATED_TLDS: Record = { - 'com': ['net', 'org', 'co', 'io', 'biz'], - 'net': ['com', 'org', 'io', 'tech', 'dev'], - 'org': ['com', 'net', 'ngo', 'foundation', 'charity'], - 'io': ['dev', 'app', 'tech', 'ai', 'co'], - 'ai': ['io', 'tech', 'dev', 'ml', 'app'], - 'dev': ['io', 'app', 'tech', 'code', 'software'], - 'app': ['dev', 'io', 'mobile', 'software', 'tech'], - 'co': ['com', 'io', 'biz', 'company', 'inc'], - 'de': ['at', 'ch', 'eu', 'com', 'net'], - 'ch': ['de', 'at', 'li', 'eu', 'com'], - 'uk': ['co.uk', 'org.uk', 'eu', 'com', 'net'], + '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 +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 point */} + {hoveredIndex !== null && ( + <> + + + + )} + + + {/* 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 (like landing page) +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 router = useRouter() - const { isAuthenticated } = useStore() + const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() const tld = params.tld as string const [details, setDetails] = useState(null) @@ -107,10 +390,12 @@ export default function TldDetailPage() { const [chartPeriod, setChartPeriod] = useState('1Y') const [domainSearch, setDomainSearch] = useState('') const [checkingDomain, setCheckingDomain] = useState(false) - const [domainResult, setDomainResult] = useState<{ available: boolean; domain: string } | null>(null) - const [hoveredPoint, setHoveredPoint] = useState<{ x: number; y: number; price: number; date: string } | null>(null) - const [alertEmail, setAlertEmail] = useState('') - const [showAlertModal, setShowAlertModal] = useState(false) + const [domainResult, setDomainResult] = useState(null) + const [alertEnabled, setAlertEnabled] = useState(false) + + useEffect(() => { + checkAuth() + }, [checkAuth]) useEffect(() => { if (tld) { @@ -171,7 +456,7 @@ export default function TldDetailPage() { relatedData.push({ tld: relatedTld, price: data.current_price }) } } catch { - // Skip failed TLDs + // Skip failed } } setRelatedTlds(relatedData) @@ -212,8 +497,15 @@ export default function TldDetailPage() { try { const domain = domainSearch.includes('.') ? domainSearch : `${domainSearch}.${tld}` - const result = await api.checkDomain(domain, true) - setDomainResult({ available: result.is_available, domain }) + 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 { @@ -239,23 +531,26 @@ export default function TldDetailPage() { } }, [details]) - if (loading) { + const getTrendIcon = (trend: string) => { + switch (trend) { + case 'up': return + case 'down': return + default: return + } + } + + if (loading || authLoading) { return (
-
- {/* Loading skeleton */} -
-
-
-
- {[1, 2, 3].map(i => ( -
- ))} -
-
+
+ + +
+ {[1, 2, 3, 4].map(i => )}
+
@@ -264,10 +559,10 @@ export default function TldDetailPage() { if (error || !details) { return ( -
+
-
-
+
+
@@ -289,131 +584,167 @@ export default function TldDetailPage() { return (
- {/* Ambient glow */} + {/* Subtle ambient */}
-
-
+
-
+
{/* Breadcrumb */} -
) }