refactor: TLD Detail pages - remove alerts, add all table data

REMOVED:
- Alert/Notify button from both pages
- Bell icon and handleToggleAlert functions
- alertEnabled, alertLoading state

ADDED (matching table columns):
- Buy Price (1y) - was already there
- Renewal (1y) with trap warning
- 1y Change with color coding
- 3y Change with color coding
- Risk Assessment with badge (dot + reason text)

COMMAND CENTER (/command/pricing/[tld]):
- StatCards: Buy, Renew, 1y Change, 3y Change
- Risk Assessment section with badge
- Renewal Trap warning when ratio > 2x
- Real chart data from history.history API

PUBLIC (/tld-pricing/[tld]):
- Same stats in Quick Stats grid
- Risk Assessment for authenticated users
- Shimmer placeholders for non-authenticated
- Real chart data from history.history API

Both pages now show ALL info from the overview table:
 TLD name
 Trend (chart + badge)
 Buy (1y)
 Renew (1y) with trap
 1y Change
 3y Change
 Risk Level + Reason
This commit is contained in:
yves.gugger
2025-12-10 15:55:38 +01:00
parent 64785e95ce
commit d8736eac88
2 changed files with 177 additions and 155 deletions

View File

@ -1,7 +1,7 @@
'use client'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useParams } from 'next/navigation'
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
import { PageContainer, StatCard } from '@/components/PremiumTable'
import { useStore } from '@/lib/store'
@ -15,19 +15,15 @@ import {
Globe,
Building,
ExternalLink,
Bell,
Search,
ChevronRight,
Sparkles,
Check,
X,
RefreshCw,
Clock,
Shield,
Zap,
AlertTriangle,
DollarSign,
BarChart3,
Shield,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
@ -52,6 +48,12 @@ interface TldDetails {
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 {
@ -90,7 +92,7 @@ const REGISTRAR_URLS: Record<string, string> = {
type ChartPeriod = '1M' | '3M' | '1Y' | 'ALL'
// Premium Chart Component
// Premium Chart Component with real data
function PriceChart({
data,
chartStats,
@ -187,8 +189,7 @@ function PriceChart({
export default function CommandTldDetailPage() {
const params = useParams()
const router = useRouter()
const { subscription, fetchSubscription } = useStore()
const { fetchSubscription } = useStore()
const tld = params.tld as string
const [details, setDetails] = useState<TldDetails | null>(null)
@ -199,43 +200,20 @@ export default function CommandTldDetailPage() {
const [domainSearch, setDomainSearch] = useState('')
const [checkingDomain, setCheckingDomain] = useState(false)
const [domainResult, setDomainResult] = useState<DomainCheckResult | null>(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([
const [historyData, compareData, overviewData] = await Promise.all([
api.getTldHistory(tld, 365),
api.getTldCompare(tld),
api.getTldOverview(1, 0, 'popularity', tld), // Get the specific TLD data
])
if (historyData && compareData) {
@ -243,6 +221,9 @@ export default function CommandTldDetailPage() {
a.registration_price - b.registration_price
)
// Get additional data from overview API (1y, 3y change, risk)
const tldFromOverview = overviewData?.tlds?.[0]
setDetails({
tld: compareData.tld || tld,
type: compareData.type || 'generic',
@ -258,6 +239,12 @@ export default function CommandTldDetailPage() {
},
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 {
@ -344,12 +331,27 @@ export default function CommandTldDetailPage() {
const renewalInfo = getRenewalInfo()
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up': return <TrendingUp className="w-4 h-4" />
case 'down': return <TrendingDown className="w-4 h-4" />
default: return <Minus className="w-4 h-4" />
}
// Risk badge component
const getRiskBadge = () => {
if (!details) return null
const level = details.risk_level
const reason = details.risk_reason
return (
<span className={clsx(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
level === 'high' && "bg-red-500/10 text-red-400",
level === 'medium' && "bg-amber-500/10 text-amber-400",
level === 'low' && "bg-accent/10 text-accent"
)}>
<span className={clsx(
"w-2.5 h-2.5 rounded-full",
level === 'high' && "bg-red-400",
level === 'medium' && "bg-amber-400",
level === 'low' && "bg-accent"
)} />
{reason}
</span>
)
}
if (loading) {
@ -391,21 +393,6 @@ export default function CommandTldDetailPage() {
<CommandCenterLayout
title={`.${details.tld}`}
subtitle={details.description}
actions={
<button
onClick={handleToggleAlert}
disabled={alertLoading}
className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all",
alertEnabled
? "bg-accent/10 text-accent border border-accent/30"
: "bg-foreground/5 text-foreground-muted hover:text-foreground hover:bg-foreground/10"
)}
>
<Bell className={clsx("w-4 h-4", alertEnabled && "fill-current")} />
{alertEnabled ? 'Alert On' : 'Set Alert'}
</button>
}
>
<PageContainer>
{/* Breadcrumb */}
@ -417,34 +404,42 @@ export default function CommandTldDetailPage() {
<span className="text-foreground font-medium">.{details.tld}</span>
</nav>
{/* Stats Grid */}
{/* Stats Grid - All info from table */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Best Price"
title="Buy Price (1y)"
value={`$${details.pricing.min.toFixed(2)}`}
subtitle={`at ${details.cheapest_registrar}`}
icon={DollarSign}
accent
/>
<StatCard
title="Renewal"
value={renewalInfo ? `$${renewalInfo.renewal.toFixed(2)}` : '—'}
subtitle={renewalInfo?.isTrap ? `⚠️ ${renewalInfo.ratio.toFixed(1)}x registration` : 'per year'}
title="Renewal (1y)"
value={details.min_renewal_price ? `$${details.min_renewal_price.toFixed(2)}` : '—'}
subtitle={renewalInfo?.isTrap ? `${renewalInfo.ratio.toFixed(1)}x registration` : 'per year'}
icon={RefreshCw}
/>
<StatCard
title="7d Change"
value={history ? `${history.price_change_7d > 0 ? '+' : ''}${history.price_change_7d.toFixed(1)}%` : '—'}
icon={BarChart3}
title="1y Change"
value={`${details.price_change_1y > 0 ? '+' : ''}${details.price_change_1y.toFixed(0)}%`}
icon={details.price_change_1y > 0 ? TrendingUp : details.price_change_1y < 0 ? TrendingDown : Minus}
/>
<StatCard
title="Trend"
value={details.trend === 'up' ? 'Rising' : details.trend === 'down' ? 'Falling' : 'Stable'}
subtitle={details.trend_reason}
icon={details.trend === 'up' ? TrendingUp : details.trend === 'down' ? TrendingDown : Minus}
title="3y Change"
value={`${details.price_change_3y > 0 ? '+' : ''}${details.price_change_3y.toFixed(0)}%`}
icon={BarChart3}
/>
</div>
{/* Risk Level */}
<div className="flex items-center gap-4 p-4 bg-background-secondary/30 border border-border/50 rounded-xl">
<Shield className="w-5 h-5 text-foreground-muted" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">Risk Assessment</p>
<p className="text-xs text-foreground-muted">Based on renewal ratio, price volatility, and market trends</p>
</div>
{getRiskBadge()}
</div>
{/* Renewal Trap Warning */}
{renewalInfo?.isTrap && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
@ -675,4 +670,3 @@ export default function CommandTldDetailPage() {
</CommandCenterLayout>
)
}

View File

@ -1,7 +1,7 @@
'use client'
import { useEffect, useState, useMemo, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useParams } from 'next/navigation'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store'
@ -15,10 +15,8 @@ import {
Globe,
Building,
ExternalLink,
Bell,
Search,
ChevronRight,
Sparkles,
Check,
X,
Lock,
@ -26,6 +24,7 @@ import {
Clock,
Shield,
Zap,
AlertTriangle,
} from 'lucide-react'
import Link from 'next/link'
import clsx from 'clsx'
@ -50,6 +49,12 @@ interface TldDetails {
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 {
@ -79,8 +84,7 @@ interface DomainCheckResult {
expiration_date?: string | null
}
// Registrar URLs with affiliate parameters
// Note: Replace REF_CODE with actual affiliate IDs when available
// Registrar URLs
const REGISTRAR_URLS: Record<string, string> = {
'Namecheap': 'https://www.namecheap.com/domains/registration/results/?domain=',
'Porkbun': 'https://porkbun.com/checkout/search?q=',
@ -120,7 +124,7 @@ function Shimmer({ className }: { className?: string }) {
)
}
// Premium Chart Component
// Premium Chart Component with real data
function PriceChart({
data,
isAuthenticated,
@ -294,7 +298,7 @@ function PriceChart({
)
}
// Domain Check Result Card (like landing page)
// Domain Check Result Card
function DomainResultCard({
result,
tld,
@ -390,7 +394,6 @@ function DomainResultCard({
export default function TldDetailPage() {
const params = useParams()
const router = useRouter()
const { isAuthenticated, checkAuth, isLoading: authLoading, subscription, fetchSubscription } = useStore()
const tld = params.tld as string
@ -406,8 +409,6 @@ export default function TldDetailPage() {
const [domainSearch, setDomainSearch] = useState('')
const [checkingDomain, setCheckingDomain] = useState(false)
const [domainResult, setDomainResult] = useState<DomainCheckResult | null>(null)
const [alertEnabled, setAlertEnabled] = useState(false)
const [alertLoading, setAlertLoading] = useState(false)
useEffect(() => {
checkAuth()
@ -418,53 +419,25 @@ export default function TldDetailPage() {
if (tld) {
loadData()
loadRelatedTlds()
loadAlertStatus()
}
}, [tld])
// Load alert status for this TLD
const loadAlertStatus = async () => {
try {
const status = await api.getPriceAlertStatus(tld)
setAlertEnabled(status.has_alert && status.is_active)
} catch (err) {
// Ignore - user may not be logged in
}
}
// Toggle price alert
const handleToggleAlert = async () => {
if (!isAuthenticated) {
// Redirect to login
window.location.href = `/login?redirect=/tld-pricing/${tld}`
return
}
setAlertLoading(true)
try {
const result = await api.togglePriceAlert(tld)
setAlertEnabled(result.is_active)
} catch (err: any) {
console.error('Failed to toggle alert:', err)
} finally {
setAlertLoading(false)
}
}
const loadData = async () => {
try {
const [historyData, compareData] = await Promise.all([
const [historyData, compareData, overviewData] = await Promise.all([
api.getTldHistory(tld, 365),
api.getTldCompare(tld),
api.getTldOverview(1, 0, 'popularity', tld),
])
if (historyData && compareData) {
// Sort registrars by price for display
const sortedRegistrars = [...(compareData.registrars || [])].sort((a, b) =>
a.registration_price - b.registration_price
)
// Use API data directly for consistency with overview table
// Get additional data from overview API
const tldFromOverview = overviewData?.tlds?.[0]
setDetails({
tld: compareData.tld || tld,
type: compareData.type || 'generic',
@ -474,13 +447,18 @@ export default function TldDetailPage() {
trend: historyData.trend || 'stable',
trend_reason: historyData.trend_reason || 'Price tracking available',
pricing: {
// Use price_range from API for consistency with overview
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 {
@ -580,6 +558,42 @@ export default function TldDetailPage() {
}
}, [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 (
<span className={clsx(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium",
level === 'high' && "bg-red-500/10 text-red-400",
level === 'medium' && "bg-amber-500/10 text-amber-400",
level === 'low' && "bg-accent/10 text-accent"
)}>
<span className={clsx(
"w-2.5 h-2.5 rounded-full",
level === 'high' && "bg-red-400",
level === 'medium' && "bg-amber-400",
level === 'low' && "bg-accent"
)} />
{reason}
</span>
)
}
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up': return <TrendingUp className="w-4 h-4" />
@ -674,50 +688,73 @@ export default function TldDetailPage() {
<p className="text-body-lg text-foreground-muted mb-2">{details.description}</p>
<p className="text-body-sm text-foreground-subtle">{details.trend_reason}</p>
{/* Quick Stats - Only for authenticated */}
{/* Quick Stats - All data from table */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-8">
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Average</p>
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Buy (1y)</p>
{isAuthenticated ? (
<p className="text-body-lg font-medium text-foreground tabular-nums">${details.pricing.avg.toFixed(2)}</p>
<p className="text-body-lg font-medium text-foreground tabular-nums">${details.pricing.min.toFixed(2)}</p>
) : (
<Shimmer className="h-6 w-16 mt-1" />
)}
</div>
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Range</p>
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Renew (1y)</p>
{isAuthenticated ? (
<p className="text-body-lg font-medium text-foreground tabular-nums">
${details.pricing.min.toFixed(0)}${details.pricing.max.toFixed(0)}
</p>
<div className="flex items-center gap-1">
<p className="text-body-lg font-medium text-foreground tabular-nums">
${details.min_renewal_price.toFixed(2)}
</p>
{renewalInfo?.isTrap && (
<AlertTriangle className="w-4 h-4 text-amber-400" />
)}
</div>
) : (
<Shimmer className="h-6 w-20 mt-1" />
)}
</div>
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">30d Change</p>
{isAuthenticated && history ? (
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">1y Change</p>
{isAuthenticated ? (
<p className={clsx(
"text-body-lg font-medium tabular-nums",
history.price_change_30d > 0 ? "text-orange-400" :
history.price_change_30d < 0 ? "text-accent" :
details.price_change_1y > 0 ? "text-orange-400" :
details.price_change_1y < 0 ? "text-accent" :
"text-foreground"
)}>
{history.price_change_30d > 0 ? '+' : ''}{history.price_change_30d.toFixed(1)}%
{details.price_change_1y > 0 ? '+' : ''}{details.price_change_1y.toFixed(0)}%
</p>
) : (
<Shimmer className="h-6 w-14 mt-1" />
)}
</div>
<div className="p-4 bg-background-secondary/50 border border-border/50 rounded-xl">
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">Registrars</p>
<p className="text-ui-xs text-foreground-subtle uppercase tracking-wider mb-1">3y Change</p>
{isAuthenticated ? (
<p className="text-body-lg font-medium text-foreground">{details.registrars.length}</p>
<p className={clsx(
"text-body-lg font-medium tabular-nums",
details.price_change_3y > 0 ? "text-orange-400" :
details.price_change_3y < 0 ? "text-accent" :
"text-foreground"
)}>
{details.price_change_3y > 0 ? '+' : ''}{details.price_change_3y.toFixed(0)}%
</p>
) : (
<Shimmer className="h-6 w-8 mt-1" />
<Shimmer className="h-6 w-14 mt-1" />
)}
</div>
</div>
{/* Risk Assessment */}
{isAuthenticated && (
<div className="flex items-center gap-4 mt-4 p-4 bg-background-secondary/30 border border-border/50 rounded-xl">
<Shield className="w-5 h-5 text-foreground-muted" />
<div className="flex-1">
<p className="text-sm font-medium text-foreground">Risk Assessment</p>
</div>
{getRiskBadge()}
</div>
)}
</div>
{/* Right: Price Card */}
@ -745,35 +782,12 @@ export default function TldDetailPage() {
Register Domain
<ExternalLink className="w-4 h-4" />
</a>
<button
onClick={handleToggleAlert}
disabled={alertLoading}
className={clsx(
"flex items-center justify-center gap-2 w-full py-3.5 font-medium rounded-xl transition-all disabled:opacity-50",
alertEnabled
? "bg-accent/10 text-accent border border-accent/30"
: "bg-background border border-border text-foreground hover:bg-background-secondary"
)}
>
{alertLoading ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Bell className={clsx("w-4 h-4", alertEnabled && "fill-current")} />
)}
{alertLoading
? 'Updating...'
: alertEnabled
? 'Price Alert Active'
: 'Enable Price Alert'
}
</button>
</div>
{savings && savings.amount > 0.5 && (
<div className="mt-5 pt-5 border-t border-border/50">
<div className="flex items-start gap-2.5">
<Sparkles className="w-4 h-4 text-accent mt-0.5 shrink-0" />
<Zap className="w-4 h-4 text-accent mt-0.5 shrink-0" />
<p className="text-ui-sm text-foreground-muted leading-relaxed">
Save <span className="text-accent font-medium">${savings.amount.toFixed(2)}</span>/yr vs {savings.expensiveName}
</p>
@ -798,6 +812,20 @@ export default function TldDetailPage() {
</div>
</div>
{/* Renewal Trap Warning */}
{isAuthenticated && renewalInfo?.isTrap && (
<div className="mb-8 p-4 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-400">Renewal Trap Detected</p>
<p className="text-sm text-foreground-muted mt-1">
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.
</p>
</div>
</div>
)}
{/* Price Chart */}
<section className="mb-12">
<div className="flex items-center justify-between mb-4">
@ -982,7 +1010,7 @@ export default function TldDetailPage() {
${registrar.renewal_price.toFixed(2)}
</span>
{registrar.renewal_price > registrar.registration_price * 1.5 && (
<span className="ml-1.5 text-orange-400" title="High renewal"></span>
<AlertTriangle className="inline-block ml-1.5 w-3.5 h-3.5 text-amber-400" />
)}
</td>
<td className="px-5 py-4 text-right hidden sm:table-cell">
@ -1093,10 +1121,10 @@ export default function TldDetailPage() {
Monitor specific domains and get instant notifications when they become available.
</p>
<Link
href={isAuthenticated ? '/dashboard' : '/register'}
href={isAuthenticated ? '/command' : '/register'}
className="inline-flex items-center gap-2 px-8 py-4 bg-foreground text-background text-ui font-medium rounded-xl hover:bg-foreground/90 transition-all"
>
{isAuthenticated ? 'Go to Dashboard' : 'Start Monitoring Free'}
{isAuthenticated ? 'Go to Command Center' : 'Start Monitoring Free'}
<ChevronRight className="w-4 h-4" />
</Link>
</section>