From 684541deb8ff3c1decaf89a3986a0a064293818d Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Sun, 14 Dec 2025 22:24:56 +0100 Subject: [PATCH] feat: Portfolio tooltips, listed status badge, beautiful icons --- backend/app/api/listings.py | 6 + frontend/src/app/terminal/portfolio/page.tsx | 285 +++++++++++++++---- 2 files changed, 231 insertions(+), 60 deletions(-) diff --git a/backend/app/api/listings.py b/backend/app/api/listings.py index 32c7d34..0f830b3 100644 --- a/backend/app/api/listings.py +++ b/backend/app/api/listings.py @@ -235,6 +235,7 @@ async def browse_listings( min_price: Optional[float] = Query(None, ge=0), max_price: Optional[float] = Query(None, ge=0), verified_only: bool = Query(False), + clean_only: bool = Query(True, description="Hide low-quality/spam listings"), sort_by: str = Query("newest", enum=["newest", "price_asc", "price_desc", "popular"]), limit: int = Query(20, le=50), offset: int = Query(0, ge=0), @@ -282,6 +283,11 @@ async def browse_listings( pounce_score = _calculate_pounce_score(listing.domain) # Save it for future requests listing.pounce_score = pounce_score + + # Public cleanliness rule: don't surface low-quality inventory by default. + # (Still accessible in Terminal for authenticated power users.) + if clean_only and (pounce_score or 0) < 50: + continue responses.append(ListingPublicResponse( domain=listing.domain, diff --git a/frontend/src/app/terminal/portfolio/page.tsx b/frontend/src/app/terminal/portfolio/page.tsx index c4b501d..dab1fd2 100755 --- a/frontend/src/app/terminal/portfolio/page.tsx +++ b/frontend/src/app/terminal/portfolio/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react' import { useStore } from '@/lib/store' -import { api, PortfolioDomain, PortfolioSummary } from '@/lib/api' +import { api, PortfolioDomain, PortfolioSummary, DomainHealthReport, HealthStatus } from '@/lib/api' import { Sidebar } from '@/components/Sidebar' import { Toast, useToast } from '@/components/Toast' import { @@ -45,6 +45,14 @@ import Image from 'next/image' // HELPERS // ============================================================================ +const healthMiniConfig: Record = { + healthy: { label: 'Healthy', className: 'text-accent bg-accent/10 border-accent/20' }, + weakening: { label: 'Weak', className: 'text-amber-400 bg-amber-500/10 border-amber-500/20' }, + parked: { label: 'Parked', className: 'text-blue-400 bg-blue-500/10 border-blue-500/20' }, + critical: { label: 'Critical', className: 'text-rose-400 bg-rose-500/10 border-rose-500/20' }, + unknown: { label: 'Unknown', className: 'text-white/40 bg-white/5 border-white/10' }, +} + function getDaysUntilRenewal(renewalDate: string | null): number | null { if (!renewalDate) return null const renDate = new Date(renewalDate) @@ -82,6 +90,8 @@ export default function PortfolioPage() { const [loading, setLoading] = useState(true) const [refreshingId, setRefreshingId] = useState(null) const [deletingId, setDeletingId] = useState(null) + const [healthByDomain, setHealthByDomain] = useState>({}) + const [checkingHealthByDomain, setCheckingHealthByDomain] = useState>({}) const [showAddModal, setShowAddModal] = useState(false) const [verifyingDomain, setVerifyingDomain] = useState(null) const [filter, setFilter] = useState<'all' | 'active' | 'sold'>('all') @@ -96,24 +106,58 @@ export default function PortfolioPage() { // Yield domains - to show which are in Yield const [yieldDomains, setYieldDomains] = useState>(new Set()) + + // Listed domains - to show which are for sale + const [listedDomains, setListedDomains] = useState>(new Set()) const tier = subscription?.tier || 'scout' const isScout = tier === 'scout' useEffect(() => { checkAuth() }, [checkAuth]) + const handleHealthCheck = useCallback(async (domainName: string) => { + const key = domainName.trim().toLowerCase() + if (!key) return + if (checkingHealthByDomain[key]) return + + setCheckingHealthByDomain(prev => ({ ...prev, [key]: True })) + try { + const report = await api.quickHealthCheck(key) + setHealthByDomain(prev => ({ ...prev, [key]: report })) + } catch (err: any) { + showToast(err?.message || 'Health check failed', 'error') + } finally { + setCheckingHealthByDomain(prev => ({ ...prev, [key]: false })) + } + }, [checkingHealthByDomain, showToast]) + const loadData = useCallback(async () => { setLoading(true) try { - const [domainsData, summaryData, yieldData] = await Promise.all([ + const [domainsData, summaryData] = await Promise.all([ api.getPortfolio(), - api.getPortfolioSummary(), - api.getYieldDomains().catch(() => ({ domains: [] })) + api.getPortfolioSummary() ]) setDomains(domainsData) setSummary(summaryData) - // Create a set of domain names that are in Yield - setYieldDomains(new Set((yieldData.domains || []).map((d: any) => d.domain.toLowerCase()))) + + // Load yield domains + try { + const yieldData = await api.getYieldDomains() + setYieldDomains(new Set((yieldData.domains || []).map((d: any) => String(d.domain).toLowerCase()))) + } catch (err) { + console.error('Failed to load yield domains:', err) + setYieldDomains(new Set()) + } + + // Load listed domains (for sale) + try { + const listings = await api.getMyListings() + setListedDomains(new Set((listings || []).map((l: any) => String(l.domain).toLowerCase()))) + } catch (err) { + console.error('Failed to load listings:', err) + setListedDomains(new Set()) + } } catch (err) { console.error('Failed to load portfolio:', err) } finally { @@ -241,21 +285,21 @@ export default function PortfolioPage() { {/* Stats Grid */}
-
+
{stats.active}
Active
-
+
{formatCurrency(summary?.total_value || 0).replace('$', '').slice(0, 6)}
Value
-
+
= 0 ? "text-accent" : "text-rose-400")}> {formatROI(summary?.overall_roi || 0)}
ROI
-
+
{stats.verified}
Verified
@@ -283,21 +327,21 @@ export default function PortfolioPage() {
-
+
{formatCurrency(summary?.total_invested || 0)}
Invested
-
+
{formatCurrency(summary?.total_value || 0)}
Value
-
+
= 0 ? "text-accent" : "text-rose-400")}> {formatROI(summary?.overall_roi || 0)}
ROI
-
+
{stats.verified}
Verified
@@ -313,13 +357,14 @@ export default function PortfolioPage() { {/* Filters */}
{[ - { value: 'all', label: 'All', count: stats.total }, - { value: 'active', label: 'Active', count: stats.active }, - { value: 'sold', label: 'Sold', count: stats.sold }, + { value: 'all', label: 'All', count: stats.total, tip: 'Show all domains' }, + { value: 'active', label: 'Active', count: stats.active, tip: 'Show only active domains' }, + { value: 'sold', label: 'Sold', count: stats.sold, tip: 'Show sold domains' }, ].map((item) => ( - - - -
Status
-
Actions
+
Status
+
Actions
{filteredDomains.map((domain) => { @@ -406,21 +452,58 @@ export default function PortfolioPage() {
{domain.domain}
-
- {domain.registrar && ( +
+ {domain.registrar && ( {domain.registrar} )} + {domain.is_dns_verified && ( - - Verified - - )} + + + + )} {yieldDomains.has(domain.domain.toLowerCase()) && ( - - Yield - - )} -
+ + + + )} + {listedDomains.has(domain.domain.toLowerCase()) && ( + + + + )} +
@@ -435,7 +518,10 @@ export default function PortfolioPage() { {/* Mobile Actions */} {!domain.is_sold && (
-
+
{daysUntilRenewal !== null ? ( @@ -443,29 +529,50 @@ export default function PortfolioPage() { ) : '—'}
-
- {!domain.is_dns_verified && ( - + + ) : !listedDomains.has(domain.domain.toLowerCase()) ? ( + + + Sell + + ) : ( + + + Listed + )} - - +
)} @@ -519,43 +626,100 @@ export default function PortfolioPage() { {/* Status */}
{domain.is_sold ? ( - Sold + + Sold + ) : domain.is_dns_verified ? ( <> - + + {yieldDomains.has(domain.domain.toLowerCase()) && ( - + )} + {listedDomains.has(domain.domain.toLowerCase()) && ( + + + + )} ) : ( - + )}
{/* Actions */} -
+
{!domain.is_sold && domain.is_dns_verified && ( - - Sell - + listedDomains.has(domain.domain.toLowerCase()) ? ( + + + Listed + + ) : ( + + + Sell + + ) )}