fix: Portfolio page redesign, unified DNS verification, fix API route conflicts
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

This commit is contained in:
2025-12-14 21:44:40 +01:00
parent 8051b2ac51
commit 1f72f2664d
23 changed files with 740 additions and 1214 deletions

View File

@ -548,6 +548,10 @@ async def create_listing(
detail="Cannot list a sold domain for sale.",
)
# Check if domain is DNS verified in portfolio
# If verified in portfolio, listing inherits verification
is_portfolio_verified = getattr(portfolio_domain, 'is_dns_verified', False) or False
# Check if domain is already listed
existing = await db.execute(
select(DomainListing).where(DomainListing.domain == domain_lower)
@ -600,6 +604,7 @@ async def create_listing(
estimated_value = None
# Create listing
# If portfolio domain is already DNS verified, listing is auto-verified
listing = DomainListing(
user_id=current_user.id,
domain=domain_lower,
@ -614,7 +619,9 @@ async def create_listing(
allow_offers=data.allow_offers,
pounce_score=pounce_score,
estimated_value=estimated_value,
verification_code=_generate_verification_code(),
verification_code=portfolio_domain.verification_code if is_portfolio_verified else _generate_verification_code(),
verification_status=VerificationStatus.VERIFIED.value if is_portfolio_verified else VerificationStatus.PENDING.value,
verified_at=portfolio_domain.verified_at if is_portfolio_verified else None,
status=ListingStatus.DRAFT.value,
)

View File

@ -176,7 +176,112 @@ class ValuationResponse(BaseModel):
disclaimer: str
# ============== Helper Functions ==============
def _generate_verification_code() -> str:
"""Generate a unique verification code."""
return f"pounce-verify-{secrets.token_hex(8)}"
def _domain_to_response(domain: PortfolioDomain) -> PortfolioDomainResponse:
"""Convert PortfolioDomain to response schema."""
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
verification_code=getattr(domain, 'verification_code', None),
verified_at=getattr(domain, 'verified_at', None),
created_at=domain.created_at,
updated_at=domain.updated_at,
)
# ============== Portfolio Endpoints ==============
# IMPORTANT: Static routes must come BEFORE dynamic routes like /{domain_id}
@router.get("/verified", response_model=List[PortfolioDomainResponse])
async def get_verified_domains(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Get only DNS-verified portfolio domains.
These domains can be used for Yield or For Sale listings.
"""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.user_id == current_user.id,
PortfolioDomain.is_dns_verified == True,
PortfolioDomain.is_sold == False,
)
).order_by(PortfolioDomain.domain.asc())
)
domains = result.scalars().all()
return [_domain_to_response(d) for d in domains]
@router.get("/summary", response_model=PortfolioSummary)
async def get_portfolio_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get portfolio summary statistics."""
result = await db.execute(
select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
)
domains = result.scalars().all()
total_domains = len(domains)
active_domains = sum(1 for d in domains if d.status == "active" and not d.is_sold)
sold_domains = sum(1 for d in domains if d.is_sold)
total_invested = sum(d.purchase_price or 0 for d in domains)
total_value = sum(d.estimated_value or 0 for d in domains if not d.is_sold)
total_sold_value = sum(d.sale_price or 0 for d in domains if d.is_sold)
# Calculate active investment for ROI
active_investment = sum(d.purchase_price or 0 for d in domains if not d.is_sold)
sold_investment = sum(d.purchase_price or 0 for d in domains if d.is_sold)
unrealized_profit = total_value - active_investment
realized_profit = total_sold_value - sold_investment
overall_roi = 0.0
if total_invested > 0:
overall_roi = ((total_value + total_sold_value - total_invested) / total_invested) * 100
return PortfolioSummary(
total_domains=total_domains,
active_domains=active_domains,
sold_domains=sold_domains,
total_invested=round(total_invested, 2),
total_value=round(total_value, 2),
total_sold_value=round(total_sold_value, 2),
unrealized_profit=round(unrealized_profit, 2),
realized_profit=round(realized_profit, 2),
overall_roi=round(overall_roi, 2),
)
@router.get("", response_model=List[PortfolioDomainResponse])
async def get_portfolio(
@ -242,49 +347,6 @@ async def get_portfolio(
return responses
@router.get("/summary", response_model=PortfolioSummary)
async def get_portfolio_summary(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Get portfolio summary statistics."""
result = await db.execute(
select(PortfolioDomain).where(PortfolioDomain.user_id == current_user.id)
)
domains = result.scalars().all()
total_domains = len(domains)
active_domains = sum(1 for d in domains if d.status == "active" and not d.is_sold)
sold_domains = sum(1 for d in domains if d.is_sold)
total_invested = sum(d.purchase_price or 0 for d in domains)
total_value = sum(d.estimated_value or 0 for d in domains if not d.is_sold)
total_sold_value = sum(d.sale_price or 0 for d in domains if d.is_sold)
# Calculate active investment for ROI
active_investment = sum(d.purchase_price or 0 for d in domains if not d.is_sold)
sold_investment = sum(d.purchase_price or 0 for d in domains if d.is_sold)
unrealized_profit = total_value - active_investment
realized_profit = total_sold_value - sold_investment
overall_roi = 0.0
if total_invested > 0:
overall_roi = ((total_value + total_sold_value - total_invested) / total_invested) * 100
return PortfolioSummary(
total_domains=total_domains,
active_domains=active_domains,
sold_domains=sold_domains,
total_invested=round(total_invested, 2),
total_value=round(total_value, 2),
total_sold_value=round(total_sold_value, 2),
unrealized_profit=round(unrealized_profit, 2),
realized_profit=round(realized_profit, 2),
overall_roi=round(overall_roi, 2),
)
@router.post("", response_model=PortfolioDomainResponse, status_code=status.HTTP_201_CREATED)
async def add_portfolio_domain(
data: PortfolioDomainCreate,
@ -670,42 +732,6 @@ async def get_domain_valuation(
# ============== DNS Verification Endpoints ==============
def _generate_verification_code() -> str:
"""Generate a unique verification code."""
return f"pounce-verify-{secrets.token_hex(8)}"
def _domain_to_response(domain: PortfolioDomain) -> PortfolioDomainResponse:
"""Convert PortfolioDomain to response schema."""
return PortfolioDomainResponse(
id=domain.id,
domain=domain.domain,
purchase_date=domain.purchase_date,
purchase_price=domain.purchase_price,
purchase_registrar=domain.purchase_registrar,
registrar=domain.registrar,
renewal_date=domain.renewal_date,
renewal_cost=domain.renewal_cost,
auto_renew=domain.auto_renew,
estimated_value=domain.estimated_value,
value_updated_at=domain.value_updated_at,
is_sold=domain.is_sold,
sale_date=domain.sale_date,
sale_price=domain.sale_price,
status=domain.status,
notes=domain.notes,
tags=domain.tags,
roi=domain.roi,
# Use getattr with defaults for new fields that may not exist in DB yet
is_dns_verified=getattr(domain, 'is_dns_verified', False) or False,
verification_status=getattr(domain, 'verification_status', 'unverified') or 'unverified',
verification_code=getattr(domain, 'verification_code', None),
verified_at=getattr(domain, 'verified_at', None),
created_at=domain.created_at,
updated_at=domain.updated_at,
)
@router.post("/{domain_id}/verify-dns", response_model=DNSVerificationStartResponse)
async def start_dns_verification(
domain_id: int,
@ -860,27 +886,3 @@ async def check_dns_verification(
message=f"TXT record found but value doesn't match. Expected: {domain.verification_code}",
)
@router.get("/verified", response_model=List[PortfolioDomainResponse])
async def get_verified_domains(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Get only DNS-verified portfolio domains.
These domains can be used for Yield or For Sale listings.
"""
result = await db.execute(
select(PortfolioDomain).where(
and_(
PortfolioDomain.user_id == current_user.id,
PortfolioDomain.is_dns_verified == True,
PortfolioDomain.is_sold == False,
)
).order_by(PortfolioDomain.domain.asc())
)
domains = result.scalars().all()
return [_domain_to_response(d) for d in domains]

View File

@ -143,7 +143,7 @@ export default function AboutPage() {
className="px-8 py-4 bg-accent text-black text-xs font-bold uppercase tracking-[0.2em] hover:bg-white transition-all shadow-[0_0_20px_rgba(16,185,129,0.2)]"
style={{ clipPath: 'polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px)' }}
>
Start Hunting
Enter Terminal
</Link>
<Link
href="/contact"

View File

@ -4,7 +4,7 @@ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
export const marketMetadata: Metadata = {
title: 'Live Domain Market - Auctions, Drops & Premium Domains',
description: 'Real-time domain marketplace aggregating auctions from GoDaddy, Sedo, Dynadot, and premium domain listings. Spam-filtered feed. Search 100,000+ domains. Buy, sell, or bid now.',
description: 'Live domain marketplace aggregating auctions and Pounce Direct listings. Cut noise with filters. Pounce Direct owners are DNS-verified and sell with 0% commission.',
keywords: [
'domain marketplace',
'domain auctions',
@ -21,7 +21,7 @@ export const marketMetadata: Metadata = {
],
openGraph: {
title: 'Live Domain Market - Pounce',
description: 'Real-time domain marketplace. Auctions, drops, and premium listings. Spam-filtered. Search 100,000+ domains.',
description: 'Live domain marketplace. Auctions and Pounce Direct listings. Filters, pricing intel, and verified sellers.',
url: `${siteUrl}/market`,
type: 'website',
images: [
@ -36,7 +36,7 @@ export const marketMetadata: Metadata = {
twitter: {
card: 'summary_large_image',
title: 'Live Domain Market - Pounce',
description: 'Real-time domain marketplace. Auctions, drops, and premium listings. Spam-filtered.',
description: 'Live domain marketplace. Auctions and Pounce Direct listings. Filters and verified sellers.',
images: [`${siteUrl}/og-market.png`],
},
alternates: {
@ -52,7 +52,7 @@ export function getMarketStructuredData() {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'Live Domain Market',
description: 'Real-time domain marketplace aggregating auctions and premium listings',
description: 'Live domain marketplace aggregating auctions and verified listings',
url: `${siteUrl}/market`,
breadcrumb: {
'@type': 'BreadcrumbList',

View File

@ -457,8 +457,8 @@ export default function AcquirePage() {
Acquire Assets.
</h1>
<p className="mt-6 text-lg text-white/50 max-w-2xl font-light leading-relaxed">
Global liquidity pool. Verified assets only.
<span className="block mt-2 text-white/80">Aggregated from GoDaddy, Sedo, and Pounce Direct.</span>
External auctions + Pounce Direct listings.
<span className="block mt-2 text-white/80">Pounce Direct owners are DNS-verified. External auctions are sourced from GoDaddy, Sedo, and other partners.</span>
</p>
</div>
<div className="grid grid-cols-3 gap-8 text-right">

View File

@ -47,6 +47,9 @@ export default function BlogPage() {
const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [total, setTotal] = useState(0)
const [newsletterEmail, setNewsletterEmail] = useState('')
const [newsletterState, setNewsletterState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [newsletterError, setNewsletterError] = useState<string | null>(null)
useEffect(() => {
loadBlogData()
@ -237,7 +240,7 @@ export default function BlogPage() {
</div>
<span className="inline-flex items-center gap-2 text-accent font-medium group-hover:gap-4 transition-all duration-300">
Read Article
Full briefing
<ArrowRight className="w-5 h-5" />
</span>
</div>
@ -326,7 +329,7 @@ export default function BlogPage() {
}}
className="group inline-flex items-center gap-3 px-8 py-4 bg-background-secondary/50 border border-border rounded-full text-foreground font-medium hover:border-accent/50 hover:bg-accent/5 transition-all duration-300"
>
Load More Articles
Load more briefings
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
</div>
@ -339,21 +342,50 @@ export default function BlogPage() {
<div className="absolute inset-0 bg-gradient-to-r from-accent/10 via-accent/5 to-accent/10 rounded-3xl blur-xl" />
<div className="relative p-10 sm:p-14 bg-background-secondary/50 backdrop-blur-sm border border-accent/20 rounded-3xl text-center">
<h3 className="text-2xl sm:text-3xl font-display text-foreground mb-4">
Get hunting tips in your inbox
Mission Briefings
</h3>
<p className="text-foreground-muted mb-8 max-w-xl mx-auto">
Join domain hunters who receive weekly insights, market trends, and exclusive strategies.
Weekly intel on drops, pricing traps, and market moves. No spam.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center max-w-md mx-auto">
<form
className="flex flex-col sm:flex-row gap-4 justify-center max-w-md mx-auto"
onSubmit={async (e) => {
e.preventDefault()
const email = newsletterEmail.trim()
if (!email || !email.includes('@') || !email.includes('.')) return
if (newsletterState === 'loading') return
setNewsletterState('loading')
setNewsletterError(null)
try {
await api.subscribeNewsletter(email)
setNewsletterState('success')
} catch (err: any) {
setNewsletterState('error')
setNewsletterError(err?.message || 'Subscription failed. Retry.')
}
}}
>
<input
type="email"
placeholder="Enter your email"
value={newsletterEmail}
onChange={(e) => setNewsletterEmail(e.target.value)}
placeholder="Email address"
className="flex-1 px-5 py-3.5 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50 transition-colors"
/>
<button className="px-8 py-3.5 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-colors whitespace-nowrap">
Subscribe
<button
type="submit"
disabled={newsletterState === 'loading'}
className="px-8 py-3.5 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-colors whitespace-nowrap disabled:opacity-60 disabled:cursor-not-allowed"
>
{newsletterState === 'loading' ? 'Subscribing…' : 'Subscribe'}
</button>
</div>
</form>
{newsletterState === 'success' ? (
<p className="mt-4 text-sm text-accent">Subscribed. Briefings inbound.</p>
) : newsletterState === 'error' ? (
<p className="mt-4 text-sm text-red-500">{newsletterError}</p>
) : null}
</div>
</div>
</div>

View File

@ -11,22 +11,22 @@ const contactMethods = [
{
icon: Mail,
title: 'Secure Comms',
description: 'Encrypted channel for general inquiries.',
description: 'Support, partnerships, and product intel.',
value: 'hello@pounce.ch',
href: 'mailto:hello@pounce.ch',
},
{
icon: MessageSquare,
title: 'Live Support',
description: 'Mon-Fri, 0900-1800 CET',
value: 'Open Channel',
href: '#',
title: 'Support Desk',
description: 'Email support. MonFri, 09001800 CET.',
value: 'Send ticket',
href: 'mailto:hello@pounce.ch?subject=Support',
},
{
icon: Clock,
title: 'Response Time',
description: 'Average ticket resolution.',
value: '< 4 Hours',
description: 'Typical response window.',
value: '< 24 Hours',
href: null,
},
]
@ -110,7 +110,7 @@ export default function ContactPage() {
Establish Contact.
</h1>
<p className="text-lg text-white/50 max-w-xl mx-auto font-light">
Question? Idea? Glitch in the matrix? We're listening.
Support request, bug report, or partnership intel. We&apos;ll respond fast.
</p>
</div>

View File

@ -6,6 +6,7 @@ import Script from 'next/script'
const inter = Inter({ subsets: ['latin'] })
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch'
const googleSiteVerification = process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION
export const viewport: Viewport = {
width: 'device-width',
@ -19,7 +20,7 @@ export const metadata: Metadata = {
default: 'Pounce - Domain Intelligence Platform | Find, Track & Trade Domains',
template: '%s | Pounce',
},
description: 'The #1 domain intelligence platform. Real-time auction aggregation from GoDaddy, Sedo, DropCatch & more. TLD price tracking, spam-free market feed, and portfolio management. Find undervalued domains before anyone else.',
description: 'Domain intelligence for serious investors. Scan live auctions, track drops, compare TLD pricing, and manage portfolios with a clean market feed and verified listings.',
keywords: [
'domain intelligence',
'domain auctions',
@ -65,7 +66,7 @@ export const metadata: Metadata = {
url: siteUrl,
siteName: 'Pounce',
title: 'Pounce - Domain Intelligence Platform | Find, Track & Trade Domains',
description: 'The #1 domain intelligence platform. Real-time auction aggregation, TLD price tracking, spam-free market feed. Find undervalued domains before anyone else.',
description: 'Domain intelligence for serious investors. Live auctions, TLD pricing intel, a clean market feed, and verified listings.',
images: [
{
url: `${siteUrl}/og-image.png`,
@ -78,14 +79,12 @@ export const metadata: Metadata = {
twitter: {
card: 'summary_large_image',
title: 'Pounce - Domain Intelligence Platform',
description: 'The #1 domain intelligence platform. Real-time auctions, TLD pricing, spam-free feed. Find undervalued domains.',
description: 'Domain intelligence for serious investors. Live auctions, TLD pricing intel, a clean market feed, and verified listings.',
creator: '@pouncedomains',
site: '@pouncedomains',
images: [`${siteUrl}/og-image.png`],
},
verification: {
google: 'YOUR_GOOGLE_VERIFICATION_CODE', // Add your Google Search Console verification
},
verification: googleSiteVerification ? { google: googleSiteVerification } : undefined,
robots: {
index: true,
follow: true,
@ -139,7 +138,7 @@ export default function RootLayout({
],
contactPoint: {
'@type': 'ContactPoint',
email: 'hello@pounce.com',
email: 'hello@pounce.ch',
contactType: 'Customer Service',
},
}),

View File

@ -4,7 +4,7 @@ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
export const homeMetadata: Metadata = {
title: 'Pounce - Domain Intelligence for Investors | The Market Never Sleeps',
description: 'Domain intelligence platform for investors. Real-time drops, spam-filtered auctions, TLD price tracking, portfolio monitoring. Scout, track, and trade premium domains. 0% marketplace commission.',
description: 'Domain intelligence for investors. Live auctions, drops, TLD price tracking, and portfolio monitoring with a clean market feed and verified listings. 0% marketplace commission.',
keywords: [
'domain intelligence',
'domain marketplace',
@ -23,7 +23,7 @@ export const homeMetadata: Metadata = {
],
openGraph: {
title: 'Pounce - Domain Intelligence for Investors',
description: 'The market never sleeps. You should. Real-time domain intelligence, auctions, and market data.',
description: 'The market never sleeps. You should. Live domain intelligence, auctions, and market data.',
url: siteUrl,
type: 'website',
images: [
@ -38,7 +38,7 @@ export const homeMetadata: Metadata = {
twitter: {
card: 'summary_large_image',
title: 'Pounce - Domain Intelligence for Investors',
description: 'The market never sleeps. You should. Real-time domain intelligence.',
description: 'The market never sleeps. You should. Live domain intelligence.',
images: [`${siteUrl}/og-image.png`],
},
alternates: {
@ -66,7 +66,7 @@ export function getHomeStructuredData() {
],
contactPoint: {
'@type': 'ContactPoint',
email: 'hello@pounce.com',
email: 'hello@pounce.ch',
contactType: 'Customer Service',
availableLanguage: ['en'],
},
@ -108,7 +108,7 @@ export function getHomeStructuredData() {
worstRating: '1',
},
featureList: [
'Real-time domain monitoring',
'Live domain monitoring',
'Spam-filtered auction feed',
'TLD price intelligence',
'Portfolio management',

View File

@ -4,7 +4,7 @@ const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
export const pricingMetadata: Metadata = {
title: 'Pricing Plans - Domain Intelligence & Market Access',
description: 'Choose your domain intelligence plan. Scout (Free), Trader ($9/mo), or Tycoon ($29/mo). Real-time market data, spam-filtered auctions, and portfolio monitoring. 0% commission on marketplace sales.',
description: 'Choose your domain intelligence plan. Scout (Free), Trader ($9/mo), or Tycoon ($29/mo). Live market data, spam-filtered auctions, and portfolio monitoring. 0% commission on Pounce Direct sales.',
keywords: [
'domain intelligence pricing',
'domain monitoring subscription',
@ -17,7 +17,7 @@ export const pricingMetadata: Metadata = {
],
openGraph: {
title: 'Pricing Plans - Pounce Domain Intelligence',
description: 'Scout (Free), Trader ($9/mo), Tycoon ($29/mo). Real-time market data, spam-filtered auctions, portfolio monitoring.',
description: 'Scout (Free), Trader ($9/mo), Tycoon ($29/mo). Live market data, spam-filtered auctions, and portfolio monitoring.',
url: `${siteUrl}/pricing`,
type: 'website',
images: [
@ -32,7 +32,7 @@ export const pricingMetadata: Metadata = {
twitter: {
card: 'summary_large_image',
title: 'Pricing Plans - Pounce Domain Intelligence',
description: 'Scout (Free), Trader ($9/mo), Tycoon ($29/mo). Start hunting domains today.',
description: 'Scout (Free), Trader ($9/mo), Tycoon ($29/mo). Clean market feed, faster monitoring, and verified listings.',
images: [`${siteUrl}/og-pricing.png`],
},
alternates: {

View File

@ -17,7 +17,7 @@ const tiers = [
icon: Zap,
price: '0',
period: '',
description: 'Test the waters. Zero risk.',
description: 'Recon access. No commitment.',
features: [
{ text: 'Market Feed', highlight: false, available: true, sublabel: 'Raw' },
{ text: 'Alert Speed', highlight: false, available: true, sublabel: 'Daily' },
@ -28,7 +28,7 @@ const tiers = [
{ text: 'Marketplace', highlight: false, available: true, sublabel: 'Buy Only' },
{ text: 'Yield (Intent Routing)', highlight: false, available: false },
],
cta: 'Start Free',
cta: 'Enter Terminal',
highlighted: false,
badge: null,
isPaid: false,
@ -39,7 +39,7 @@ const tiers = [
icon: TrendingUp,
price: '9',
period: '/mo',
description: 'The smart investor\'s choice.',
description: 'Cut noise. Move faster.',
features: [
{ text: 'Market Feed', highlight: true, available: true, sublabel: 'Curated' },
{ text: 'Alert Speed', highlight: true, available: true, sublabel: 'Hourly' },
@ -62,10 +62,10 @@ const tiers = [
icon: Crown,
price: '29',
period: '/mo',
description: 'For serious domain investors.',
description: 'Full firepower. Priority routes.',
features: [
{ text: 'Market Feed', highlight: true, available: true, sublabel: 'Priority' },
{ text: 'Alert Speed', highlight: true, available: true, sublabel: 'Real-Time' },
{ text: 'Alert Speed', highlight: true, available: true, sublabel: '10 min' },
{ text: '500 Watchlist Domains', highlight: true, available: true },
{ text: '50 Sniper Alerts', highlight: true, available: true },
{ text: 'TLD Intel', highlight: true, available: true, sublabel: 'Full History' },
@ -83,7 +83,7 @@ const tiers = [
const comparisonFeatures = [
{ name: 'Market Feed', scout: 'Raw (Unfiltered)', trader: 'Curated (Spam-Free)', tycoon: 'Curated + Priority' },
{ name: 'Alert Speed', scout: 'Daily', trader: 'Hourly', tycoon: 'Real-Time (10 min)' },
{ name: 'Alert Speed', scout: 'Daily', trader: 'Hourly', tycoon: 'Every 10 minutes' },
{ name: 'Watchlist', scout: '5 Domains', trader: '50 Domains', tycoon: '500 Domains' },
{ name: 'Sniper Alerts', scout: '2', trader: '10', tycoon: '50' },
{ name: 'TLD Intel', scout: 'Public Trends', trader: 'Renewal Prices', tycoon: 'Full History' },
@ -211,7 +211,7 @@ export default function PricingPage() {
Pick your weapon.
</h1>
<p className="mt-5 sm:mt-8 text-sm sm:text-lg lg:text-xl text-white/50 max-w-xl lg:mx-auto font-light leading-relaxed">
Start free. Scale when you&apos;re ready. All plans include core features.
Enter Terminal. Upgrade when you need speed, filters, and deeper intel.
</p>
</div>

View File

@ -255,7 +255,7 @@ function RegisterForm() {
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
Start Hunting
Enter Terminal
<ArrowRight className="w-4 h-4" />
</>
)}

View File

@ -146,12 +146,12 @@ export default function MyListingsPage() {
<div className="w-20 h-20 bg-amber-400/10 border border-amber-400/20 flex items-center justify-center mx-auto mb-6">
<Lock className="w-10 h-10 text-amber-400" />
</div>
<h1 className="font-display text-3xl text-white mb-4">Sell Your Domains</h1>
<h1 className="font-display text-3xl text-white mb-4">For Sale</h1>
<p className="text-white/50 text-sm font-mono mb-2">
List your domains directly on Pounce Market.
List domains on Pounce Direct.
</p>
<p className="text-white/30 text-xs font-mono mb-8">
0% commission. Verified ownership. Instant visibility.
0% commission. DNS-verified ownership. Direct buyer contact.
</p>
<div className="bg-white/[0.02] border border-white/[0.08] p-6 mb-6">
@ -185,7 +185,7 @@ export default function MyListingsPage() {
</div>
<Link href="/pricing" className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white transition-colors">
<Sparkles className="w-4 h-4" />Upgrade Now
<Sparkles className="w-4 h-4" />Upgrade
</Link>
</div>
</div>
@ -239,7 +239,7 @@ export default function MyListingsPage() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">💎 Pounce Direct</span>
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Pounce Direct</span>
</div>
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em]">
<span className="text-white">For Sale</span>
@ -569,10 +569,17 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
})
setCreatedListing(listing)
// Start DNS verification
const verification = await api.startDnsVerification(listing.id)
setVerificationData(verification)
setStep(2)
// Check if domain was already verified in portfolio
if (listing.is_verified) {
// Skip verification step, go directly to publish
setVerified(true)
setStep(3)
} else {
// Start DNS verification
const verification = await api.startDnsVerification(listing.id)
setVerificationData(verification)
setStep(2)
}
} catch (err: any) {
setError(err.message || 'Failed to create listing')
} finally {
@ -823,7 +830,7 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
</div>
<div className="p-3 bg-accent/5 border border-accent/20 text-[11px] font-mono text-white/60 leading-relaxed">
Your listing will appear in the <strong>Market Feed</strong> with the <span className="text-accent">💎 Pounce Direct</span> badge.
Your listing will appear in the <strong>Market Feed</strong> with the <span className="text-accent">Pounce Direct</span> badge.
</div>
<button

File diff suppressed because it is too large Load Diff

View File

@ -47,11 +47,30 @@ import Image from 'next/image'
interface HotAuction {
domain: string
current_bid: number
time_remaining: string
end_time?: string
platform: string
affiliate_url?: string
}
function calcTimeRemaining(endTimeIso?: string): string {
if (!endTimeIso) return 'N/A'
const end = new Date(endTimeIso).getTime()
const now = Date.now()
const diff = end - now
if (diff <= 0) return 'Ended'
const seconds = Math.floor(diff / 1000)
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
if (mins > 0) return `${mins}m`
return '< 1m'
}
interface SearchResult {
domain: string
status: string
@ -74,6 +93,7 @@ export default function RadarPage() {
const [hotAuctions, setHotAuctions] = useState<HotAuction[]>([])
const [marketStats, setMarketStats] = useState({ totalAuctions: 0, endingSoon: 0 })
const [loadingData, setLoadingData] = useState(true)
const [tick, setTick] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [searchResult, setSearchResult] = useState<SearchResult | null>(null)
@ -96,33 +116,27 @@ export default function RadarPage() {
// Load Data - Using same API as Market page for consistency
const loadDashboardData = useCallback(async () => {
try {
// Use getMarketFeed for consistency with Market & Acquire pages
const result = await api.getMarketFeed({
source: 'all',
sortBy: 'time',
limit: 10, // Show top 10 ending soon
})
// Convert to HotAuction format
const auctions = (result.items || [])
// External auctions only (Pounce Direct has no end_time)
const [feed, ending24h] = await Promise.all([
api.getMarketFeed({ source: 'external', sortBy: 'time', limit: 10 }),
api.getMarketFeed({ source: 'external', endingWithin: 24, sortBy: 'time', limit: 1 }),
])
const auctions: HotAuction[] = (feed.items || [])
.filter((item: any) => item.status === 'auction' && item.end_time)
.slice(0, 6)
.map((item: any) => ({
domain: item.domain,
current_bid: item.price || 0,
time_remaining: item.time_remaining || '',
end_time: item.end_time || undefined,
platform: item.source || 'Unknown',
affiliate_url: item.url || '',
}))
setHotAuctions(auctions)
setMarketStats({
totalAuctions: result.auction_count || result.total || 0,
endingSoon: result.items?.filter((i: any) => {
if (!i.end_time) return false
const hoursLeft = (new Date(i.end_time).getTime() - Date.now()) / (1000 * 60 * 60)
return hoursLeft > 0 && hoursLeft <= 24
}).length || 0,
totalAuctions: feed.total || feed.auction_count || 0,
endingSoon: ending24h.total || 0,
})
} catch (error) {
console.error('Failed to load data:', error)
@ -135,6 +149,8 @@ export default function RadarPage() {
if (!authLoading) {
if (isAuthenticated) {
loadDashboardData()
const interval = setInterval(() => setTick(t => t + 1), 30000)
return () => clearInterval(interval)
} else {
setLoadingData(false)
}
@ -149,18 +165,13 @@ export default function RadarPage() {
case 'domain': return mult * a.domain.localeCompare(b.domain)
case 'bid': return mult * (a.current_bid - b.current_bid)
case 'time':
// Parse time_remaining like "2h 30m" or "5d 12h"
const parseTime = (t: string) => {
const d = t.match(/(\d+)d/)?.[1] || 0
const h = t.match(/(\d+)h/)?.[1] || 0
const m = t.match(/(\d+)m/)?.[1] || 0
return Number(d) * 86400 + Number(h) * 3600 + Number(m) * 60
}
return mult * (parseTime(a.time_remaining) - parseTime(b.time_remaining))
const aTime = a.end_time ? new Date(a.end_time).getTime() : Infinity
const bTime = b.end_time ? new Date(b.end_time).getTime() : Infinity
return mult * (aTime - bTime)
default: return 0
}
})
}, [hotAuctions, auctionSort, auctionSortDir])
}, [hotAuctions, auctionSort, auctionSortDir, tick])
const handleAuctionSort = useCallback((field: typeof auctionSort) => {
if (auctionSort === field) {
@ -289,7 +300,7 @@ export default function RadarPage() {
</div>
<div className="flex items-center gap-2 text-[10px] font-mono text-white/40">
<span>{marketStats.totalAuctions.toLocaleString()} auctions</span>
<span className="text-accent">{marketStats.endingSoon} ending</span>
<span className="text-accent">{marketStats.endingSoon} ending 24h</span>
</div>
</div>
@ -323,7 +334,7 @@ export default function RadarPage() {
<div className="hidden lg:block mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-1.5 h-1.5 bg-accent animate-pulse" />
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Intelligence Hub</span>
<span className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Domain Radar</span>
</div>
<h1 className="font-display text-[2.5rem] leading-[1] tracking-[-0.02em] text-white mb-2">
Domain Radar
@ -602,7 +613,7 @@ export default function RadarPage() {
</div>
<div className="text-[10px] font-mono text-white/40 flex items-center justify-end gap-1">
<Clock className="w-3 h-3" />
{auction.time_remaining}
{calcTimeRemaining(auction.end_time)}
</div>
</div>
</div>
@ -657,7 +668,7 @@ export default function RadarPage() {
<div className="text-center">
<span className="text-xs font-mono text-white/50 flex items-center justify-center gap-1">
<Clock className="w-3 h-3" />
{auction.time_remaining}
{calcTimeRemaining(auction.end_time)}
</span>
</div>

View File

@ -143,7 +143,7 @@ export default function SettingsPage() {
const upgraded = params.get('upgraded')
const cancelled = params.get('cancelled')
if (upgraded) {
setSuccess(`🎉 Welcome to ${upgraded.charAt(0).toUpperCase() + upgraded.slice(1)}!`)
setSuccess(`Upgrade complete: ${upgraded.charAt(0).toUpperCase() + upgraded.slice(1)}`)
setActiveTab('billing')
window.history.replaceState({}, '', '/terminal/settings')
checkAuth()

View File

@ -162,7 +162,7 @@ export default function WelcomePage() {
{/* Next Steps */}
<div className="mb-12">
<h2 className="text-lg font-semibold text-foreground mb-6 text-center">
Get Started
Next Steps
</h2>
<div className="max-w-2xl mx-auto space-y-3">
{plan.nextSteps.map((step, i) => (
@ -192,12 +192,11 @@ export default function WelcomePage() {
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-background font-medium rounded-xl
hover:bg-accent-hover transition-all shadow-lg shadow-accent/20"
>
Go to Dashboard
Go to Radar
<ArrowRight className="w-4 h-4" />
</Link>
<p className="text-sm text-foreground-muted mt-4">
Need help? Check out our <Link href="/docs" className="text-accent hover:underline">documentation</Link> or{' '}
<Link href="mailto:support@pounce.ch" className="text-accent hover:underline">contact support</Link>.
Need help? <Link href="mailto:hello@pounce.ch" className="text-accent hover:underline">Contact support</Link>.
</p>
</div>
</PageContainer>

View File

@ -255,7 +255,7 @@ export default function YieldPage() {
</button>
<button onClick={() => setShowActivateModal(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-black text-xs font-bold uppercase tracking-wider hover:bg-white">
<Plus className="w-4 h-4" />Add Domain
<Plus className="w-4 h-4" />Activate Domain
</button>
</div>
</div>
@ -266,7 +266,7 @@ export default function YieldPage() {
<section className="lg:hidden px-4 py-3 border-b border-white/[0.08]">
<button onClick={() => setShowActivateModal(true)}
className="w-full flex items-center justify-center gap-2 py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider">
<Plus className="w-4 h-4" />Add Domain
<Plus className="w-4 h-4" />Activate Domain
</button>
</section>

View File

@ -417,7 +417,7 @@ export default function TldDetailClient({ tld, initialData }: Props) {
href="/register"
className="inline-flex items-center gap-2 px-8 py-4 bg-accent text-black font-bold hover:bg-accent/90 transition-colors"
>
Start Free <ArrowRight className="w-4 h-4" />
Enter Terminal <ArrowRight className="w-4 h-4" />
</Link>
</div>
</section>

View File

@ -4,9 +4,34 @@ import Link from 'next/link'
import Image from 'next/image'
import { Twitter, Mail, Linkedin, ArrowRight } from 'lucide-react'
import { useStore } from '@/lib/store'
import { api } from '@/lib/api'
import { useCallback, useMemo, useState } from 'react'
export function Footer() {
const { isAuthenticated } = useStore()
const [newsletterEmail, setNewsletterEmail] = useState('')
const [newsletterState, setNewsletterState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
const [newsletterError, setNewsletterError] = useState<string | null>(null)
const canSubmitNewsletter = useMemo(() => {
const email = newsletterEmail.trim()
return email.length > 3 && email.includes('@') && email.includes('.')
}, [newsletterEmail])
const handleNewsletterSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmitNewsletter || newsletterState === 'loading') return
setNewsletterState('loading')
setNewsletterError(null)
try {
await api.subscribeNewsletter(newsletterEmail.trim())
setNewsletterState('success')
} catch (err: any) {
setNewsletterState('error')
setNewsletterError(err?.message || 'Subscription failed. Retry.')
}
}, [canSubmitNewsletter, newsletterEmail, newsletterState])
return (
<footer className="relative border-t border-white/10 bg-[#020202] mt-auto overflow-hidden">
@ -40,16 +65,31 @@ export function Footer() {
{/* Newsletter - Hidden on Mobile */}
<div className="hidden sm:block mb-8 max-w-sm">
<label className="text-[10px] font-mono uppercase tracking-widest text-white/30 mb-2 block">Mission Briefings</label>
<div className="flex">
<input
type="email"
placeholder="ENTER_EMAIL"
<form onSubmit={handleNewsletterSubmit} className="flex">
<input
type="email"
value={newsletterEmail}
onChange={(e) => setNewsletterEmail(e.target.value)}
placeholder="Email address"
className="w-full bg-[#0A0A0A] border border-white/10 px-4 py-3 text-white font-mono text-xs placeholder:text-white/20 focus:outline-none focus:border-accent transition-all"
/>
<button className="px-4 bg-white text-black hover:bg-accent transition-colors border-l border-white/10">
<button
type="submit"
disabled={!canSubmitNewsletter || newsletterState === 'loading'}
className="px-4 bg-white text-black hover:bg-accent transition-colors border-l border-white/10 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Subscribe to briefings"
title="Subscribe"
>
<ArrowRight className="w-4 h-4" />
</button>
</div>
</form>
{newsletterState === 'success' ? (
<p className="mt-2 text-[10px] font-mono text-accent/80">Subscribed. Briefings inbound.</p>
) : newsletterState === 'error' ? (
<p className="mt-2 text-[10px] font-mono text-rose-300/80">{newsletterError}</p>
) : (
<p className="mt-2 text-[10px] font-mono text-white/20">Weekly intel. No spam.</p>
)}
</div>
<div className="flex items-center gap-3 sm:gap-4">

View File

@ -90,7 +90,7 @@ export function Header() {
className="flex items-center gap-2 h-9 px-5 text-xs bg-accent text-black font-bold uppercase tracking-wider hover:bg-white transition-colors"
>
<Target className="w-4 h-4" />
Terminal
Enter Terminal
</Link>
) : (
<>
@ -104,7 +104,7 @@ export function Header() {
href="/register"
className="flex items-center h-9 px-5 text-xs bg-accent text-black font-bold uppercase tracking-wider hover:bg-white transition-colors"
>
Start Free
Enter Terminal
</Link>
</>
)}
@ -199,7 +199,7 @@ export function Header() {
className="flex items-center justify-center gap-2 w-full py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider active:scale-[0.98] transition-all mb-2"
>
<Target className="w-3 h-3" />
Open Terminal
Enter Terminal
</Link>
<button
@ -218,7 +218,7 @@ export function Header() {
className="flex items-center justify-center gap-2 w-full py-3 bg-accent text-black text-xs font-bold uppercase tracking-wider active:scale-[0.98] transition-all mb-2"
>
<Sparkles className="w-3 h-3" />
Start Free
Enter Terminal
</Link>
<Link

View File

@ -14,7 +14,7 @@ export interface SEOProps {
}
const defaultTitle = 'Pounce - Domain Intelligence for Investors'
const defaultDescription = 'The market never sleeps. You should. Scan, track, and trade domains with real-time drops, auctions, and TLD price intelligence. Spam-filtered. 0% commission.'
const defaultDescription = 'The market never sleeps. You should. Scan auctions, track drops, and trade domains with clean data, TLD price intel, and verified listings. Spam-filtered. 0% commission.'
const defaultKeywords = [
'domain marketplace',
'domain auctions',
@ -115,7 +115,7 @@ export function getOrganizationSchema() {
],
contactPoint: {
'@type': 'ContactPoint',
email: 'hello@pounce.com',
email: 'hello@pounce.ch',
contactType: 'Customer Service',
},
}

View File

@ -8,7 +8,7 @@ export const DEFAULT_OG_IMAGE = `${SITE_URL}/og-image.png`
export const SEO_CONFIG = {
home: {
title: 'Pounce - Domain Intelligence Platform | Find, Track & Trade Domains',
description: 'The #1 domain intelligence platform. Real-time auction aggregation, TLD price tracking, spam-free market feed, and portfolio management. Find undervalued domains before anyone else.',
description: 'Domain intelligence for serious investors. Scan live auctions, track drops, compare TLD pricing, and manage portfolios with a clean market feed and verified listings.',
keywords: [
'domain intelligence', 'domain auctions', 'expired domains', 'domain investing',
'TLD pricing', 'domain drops', 'domain marketplace', 'domain monitoring',
@ -27,7 +27,7 @@ export const SEO_CONFIG = {
},
acquire: {
title: 'Domain Auctions & Marketplace | Live Drops & Deals | Pounce',
description: 'Browse 10,000+ live domain auctions from GoDaddy, Sedo, DropCatch & more. Spam-filtered feed, real-time updates, and exclusive Pounce Direct listings with 0% commission.',
description: 'Browse live domain auctions from GoDaddy, Sedo, DropCatch & more. Use filters to cut noise, and discover Pounce Direct listings with DNS-verified owners and 0% commission.',
keywords: [
'domain auctions', 'buy domains', 'expired domain auctions', 'domain marketplace',
'godaddy auctions', 'sedo domains', 'dropcatch', 'namejet auctions',
@ -44,8 +44,8 @@ export const SEO_CONFIG = {
],
},
pricing: {
title: 'Pricing Plans | Start Free, Scale Smart | Pounce',
description: 'From hobbyist to tycoon. Scout (free), Trader ($9/mo), Tycoon ($29/mo). Real-time alerts, spam-free feeds, domain valuation, and 0% marketplace commission.',
title: 'Pricing Plans | Scout, Trader, Tycoon | Pounce',
description: 'Scout, Trader, or Tycoon. Clean market feed, faster monitoring, valuation intel, portfolio tools, and 0% commission on Pounce Direct listings. Tycoon checks every 10 minutes.',
keywords: [
'domain tool pricing', 'domain software', 'domain investing tools',
'domain monitoring service', 'domain alert service', 'domain trading platform'
@ -83,7 +83,7 @@ export function generateSoftwareApplicationSchema() {
applicationCategory: 'BusinessApplication',
operatingSystem: 'Web',
url: SITE_URL,
description: 'Real-time domain auction aggregation, TLD price tracking, and portfolio management platform',
description: 'Live domain auction aggregation, TLD price tracking, and portfolio management platform',
offers: [
{
'@type': 'Offer',
@ -106,7 +106,7 @@ export function generateSoftwareApplicationSchema() {
price: '29',
priceCurrency: 'USD',
priceValidUntil: '2025-12-31',
description: 'Enterprise tier with real-time alerts and unlimited portfolio',
description: 'Advanced tier with 10-minute monitoring, priority alerts, and unlimited portfolio',
},
],
aggregateRating: {