feat: Ultra SEO optimization - sitemap, robots, structured data, 800+ TLD pages
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
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:
9
frontend/src/app/(public)/layout.tsx
Normal file
9
frontend/src/app/(public)/layout.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
// Public pages layout - inherits from root layout
|
||||
export default function PublicLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
|
||||
68
frontend/src/app/acquire/layout.tsx
Normal file
68
frontend/src/app/acquire/layout.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { SEO_CONFIG, SITE_URL, generateWebPageSchema, generateBreadcrumbSchema } from '@/lib/seo'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: SEO_CONFIG.acquire.title,
|
||||
description: SEO_CONFIG.acquire.description,
|
||||
keywords: SEO_CONFIG.acquire.keywords,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/acquire`,
|
||||
},
|
||||
openGraph: {
|
||||
title: SEO_CONFIG.acquire.title,
|
||||
description: SEO_CONFIG.acquire.description,
|
||||
url: `${SITE_URL}/acquire`,
|
||||
siteName: 'Pounce',
|
||||
images: [
|
||||
{
|
||||
url: `${SITE_URL}/og-acquire.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Domain Auctions & Marketplace - Pounce',
|
||||
},
|
||||
],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: SEO_CONFIG.acquire.title,
|
||||
description: SEO_CONFIG.acquire.description,
|
||||
images: [`${SITE_URL}/og-acquire.png`],
|
||||
},
|
||||
}
|
||||
|
||||
export default function AcquireLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const webPageSchema = generateWebPageSchema({
|
||||
title: SEO_CONFIG.acquire.title,
|
||||
description: SEO_CONFIG.acquire.description,
|
||||
url: `${SITE_URL}/acquire`,
|
||||
})
|
||||
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: 'Home', url: SITE_URL },
|
||||
{ name: 'Domain Marketplace', url: `${SITE_URL}/acquire` },
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id="acquire-webpage-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(webPageSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="acquire-breadcrumb-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
68
frontend/src/app/discover/layout.tsx
Normal file
68
frontend/src/app/discover/layout.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { SEO_CONFIG, SITE_URL, DEFAULT_OG_IMAGE, generateWebPageSchema, generateBreadcrumbSchema } from '@/lib/seo'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: SEO_CONFIG.discover.title,
|
||||
description: SEO_CONFIG.discover.description,
|
||||
keywords: SEO_CONFIG.discover.keywords,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/discover`,
|
||||
},
|
||||
openGraph: {
|
||||
title: SEO_CONFIG.discover.title,
|
||||
description: SEO_CONFIG.discover.description,
|
||||
url: `${SITE_URL}/discover`,
|
||||
siteName: 'Pounce',
|
||||
images: [
|
||||
{
|
||||
url: `${SITE_URL}/og-discover.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'TLD Pricing & Trends - Pounce',
|
||||
},
|
||||
],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: SEO_CONFIG.discover.title,
|
||||
description: SEO_CONFIG.discover.description,
|
||||
images: [`${SITE_URL}/og-discover.png`],
|
||||
},
|
||||
}
|
||||
|
||||
export default function DiscoverLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const webPageSchema = generateWebPageSchema({
|
||||
title: SEO_CONFIG.discover.title,
|
||||
description: SEO_CONFIG.discover.description,
|
||||
url: `${SITE_URL}/discover`,
|
||||
})
|
||||
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: 'Home', url: SITE_URL },
|
||||
{ name: 'TLD Intelligence', url: `${SITE_URL}/discover` },
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id="discover-webpage-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(webPageSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="discover-breadcrumb-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ import Script from 'next/script'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch'
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
@ -16,25 +16,34 @@ export const viewport: Viewport = {
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(siteUrl),
|
||||
title: {
|
||||
default: 'Pounce - Domain Intelligence for Investors',
|
||||
default: 'Pounce - Domain Intelligence Platform | Find, Track & Trade Domains',
|
||||
template: '%s | Pounce',
|
||||
},
|
||||
description: 'The market never sleeps. You should. Scan, track, and trade domains with real-time drops, auctions, and TLD price intelligence. Spam-filtered. 0% commission.',
|
||||
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.',
|
||||
keywords: [
|
||||
'domain marketplace',
|
||||
'domain auctions',
|
||||
'TLD pricing',
|
||||
'domain investing',
|
||||
'expired domains',
|
||||
'domain intelligence',
|
||||
'domain auctions',
|
||||
'expired domains',
|
||||
'domain investing',
|
||||
'TLD pricing',
|
||||
'domain drops',
|
||||
'premium domains',
|
||||
'domain marketplace',
|
||||
'domain monitoring',
|
||||
'domain portfolio',
|
||||
'domain valuation',
|
||||
'domain market analysis',
|
||||
'premium domains',
|
||||
'domain flipping',
|
||||
'godaddy auctions',
|
||||
'sedo marketplace',
|
||||
'dropcatch',
|
||||
'namejet',
|
||||
'domain trading',
|
||||
'buy domains',
|
||||
'sell domains',
|
||||
'domain portfolio',
|
||||
'domain price trends',
|
||||
'.com domains',
|
||||
'.io domains',
|
||||
'.ai domains',
|
||||
],
|
||||
authors: [{ name: 'Pounce' }],
|
||||
creator: 'Pounce',
|
||||
@ -44,29 +53,39 @@ export const metadata: Metadata = {
|
||||
address: false,
|
||||
telephone: false,
|
||||
},
|
||||
alternates: {
|
||||
canonical: siteUrl,
|
||||
languages: {
|
||||
'en': siteUrl,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
locale: 'en_US',
|
||||
url: siteUrl,
|
||||
siteName: 'Pounce',
|
||||
title: 'Pounce - Domain Intelligence for Investors',
|
||||
description: 'The market never sleeps. You should. Real-time domain drops, auctions, and TLD price intelligence.',
|
||||
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.',
|
||||
images: [
|
||||
{
|
||||
url: `${siteUrl}/og-image.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Pounce - Domain Intelligence',
|
||||
alt: 'Pounce - Domain Intelligence Platform',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Pounce - Domain Intelligence for Investors',
|
||||
description: 'The market never sleeps. You should. Real-time domain drops, auctions, and TLD price intelligence.',
|
||||
title: 'Pounce - Domain Intelligence Platform',
|
||||
description: 'The #1 domain intelligence platform. Real-time auctions, TLD pricing, spam-free feed. Find undervalued domains.',
|
||||
creator: '@pouncedomains',
|
||||
site: '@pouncedomains',
|
||||
images: [`${siteUrl}/og-image.png`],
|
||||
},
|
||||
verification: {
|
||||
google: 'YOUR_GOOGLE_VERIFICATION_CODE', // Add your Google Search Console verification
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
|
||||
103
frontend/src/app/pricing/layout.tsx
Normal file
103
frontend/src/app/pricing/layout.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { SEO_CONFIG, SITE_URL, generateWebPageSchema, generateBreadcrumbSchema, generateSoftwareApplicationSchema, generateFAQSchema } from '@/lib/seo'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: SEO_CONFIG.pricing.title,
|
||||
description: SEO_CONFIG.pricing.description,
|
||||
keywords: SEO_CONFIG.pricing.keywords,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/pricing`,
|
||||
},
|
||||
openGraph: {
|
||||
title: SEO_CONFIG.pricing.title,
|
||||
description: SEO_CONFIG.pricing.description,
|
||||
url: `${SITE_URL}/pricing`,
|
||||
siteName: 'Pounce',
|
||||
images: [
|
||||
{
|
||||
url: `${SITE_URL}/og-pricing.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Pricing Plans - Pounce',
|
||||
},
|
||||
],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: SEO_CONFIG.pricing.title,
|
||||
description: SEO_CONFIG.pricing.description,
|
||||
images: [`${SITE_URL}/og-pricing.png`],
|
||||
},
|
||||
}
|
||||
|
||||
export default function PricingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const webPageSchema = generateWebPageSchema({
|
||||
title: SEO_CONFIG.pricing.title,
|
||||
description: SEO_CONFIG.pricing.description,
|
||||
url: `${SITE_URL}/pricing`,
|
||||
})
|
||||
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: 'Home', url: SITE_URL },
|
||||
{ name: 'Pricing', url: `${SITE_URL}/pricing` },
|
||||
])
|
||||
|
||||
const softwareSchema = generateSoftwareApplicationSchema()
|
||||
|
||||
const faqSchema = generateFAQSchema([
|
||||
{
|
||||
question: 'Is there a free plan?',
|
||||
answer: 'Yes! Scout is completely free forever. You get access to the raw market feed, 5 watchlist domains, and basic TLD intelligence.',
|
||||
},
|
||||
{
|
||||
question: 'What payment methods do you accept?',
|
||||
answer: 'We accept all major credit cards (Visa, Mastercard, Amex) through Stripe. Payments are processed securely.',
|
||||
},
|
||||
{
|
||||
question: 'Can I cancel anytime?',
|
||||
answer: 'Absolutely. No contracts, no hidden fees. Cancel your subscription anytime from your account settings.',
|
||||
},
|
||||
{
|
||||
question: 'What is the Pounce Score?',
|
||||
answer: 'The Pounce Score is our proprietary domain valuation metric that considers length, TLD, keywords, brandability, and market comparables.',
|
||||
},
|
||||
{
|
||||
question: 'Do you charge commission on sales?',
|
||||
answer: 'No! Unlike other platforms that charge 10-20% commission, Pounce Direct marketplace has 0% commission. You keep 100% of your sale price.',
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id="pricing-webpage-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(webPageSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="pricing-breadcrumb-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="pricing-software-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="pricing-faq-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
34
frontend/src/app/robots.ts
Normal file
34
frontend/src/app/robots.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import { SITE_URL } from '@/lib/seo'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
disallow: [
|
||||
'/terminal/',
|
||||
'/api/',
|
||||
'/admin/',
|
||||
'/command/',
|
||||
'/_next/',
|
||||
'/static/',
|
||||
],
|
||||
},
|
||||
{
|
||||
userAgent: 'Googlebot',
|
||||
allow: '/',
|
||||
disallow: [
|
||||
'/terminal/',
|
||||
'/api/',
|
||||
'/admin/',
|
||||
'/command/',
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,74 +1,102 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import { POPULAR_TLDS, SITE_URL } from '@/lib/seo'
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
|
||||
|
||||
// Top TLDs to include in sitemap (programmatic SEO)
|
||||
const TOP_TLDS = [
|
||||
'com', 'net', 'org', 'io', 'ai', 'co', 'app', 'dev', 'xyz', 'online',
|
||||
'tech', 'store', 'site', 'cloud', 'pro', 'info', 'biz', 'me', 'tv', 'cc',
|
||||
'de', 'uk', 'eu', 'us', 'ca', 'au', 'jp', 'fr', 'es', 'it',
|
||||
'ch', 'nl', 'se', 'no', 'dk', 'fi', 'at', 'be', 'pl', 'cz',
|
||||
'web', 'digital', 'domains', 'blog', 'shop', 'news', 'email', 'services',
|
||||
'consulting', 'agency', 'studio', 'media', 'design', 'art', 'photo', 'video',
|
||||
'crypto', 'nft', 'dao', 'defi', 'web3', 'metaverse', 'blockchain', 'bitcoin',
|
||||
'finance', 'bank', 'invest', 'trading', 'market', 'fund', 'capital', 'ventures',
|
||||
'legal', 'law', 'attorney', 'lawyer', 'consulting', 'tax', 'insurance', 'realty',
|
||||
'education', 'university', 'college', 'school', 'academy', 'training', 'courses',
|
||||
'health', 'medical', 'dental', 'clinic', 'doctor', 'care', 'fitness', 'wellness',
|
||||
'food', 'restaurant', 'cafe', 'bar', 'pizza', 'delivery', 'recipes', 'cooking',
|
||||
'travel', 'hotel', 'flights', 'tours', 'vacation', 'cruise', 'booking', 'tickets',
|
||||
'games', 'gaming', 'play', 'casino', 'bet', 'poker', 'sports', 'esports',
|
||||
'fashion', 'clothing', 'beauty', 'style', 'jewelry', 'watches', 'luxury', 'boutique',
|
||||
]
|
||||
|
||||
// This generates a dynamic sitemap including all TLD pages
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const routes: MetadataRoute.Sitemap = [
|
||||
// Main pages
|
||||
const baseUrl = SITE_URL
|
||||
const now = new Date()
|
||||
|
||||
// Static pages with high priority
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: siteUrl,
|
||||
lastModified: new Date(),
|
||||
url: baseUrl,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/market`,
|
||||
lastModified: new Date(),
|
||||
url: `${baseUrl}/discover`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/acquire`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'hourly',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/discover`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/pricing`,
|
||||
lastModified: new Date(),
|
||||
url: `${baseUrl}/yield`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/about`,
|
||||
lastModified: new Date(),
|
||||
url: `${baseUrl}/pricing`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/about`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/contact`,
|
||||
lastModified: new Date(),
|
||||
url: `${baseUrl}/blog`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/login`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.5,
|
||||
priority: 0.3,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/register`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.4,
|
||||
},
|
||||
]
|
||||
|
||||
// Add TLD pages (programmatic SEO - high priority for search)
|
||||
const tldPages: MetadataRoute.Sitemap = TOP_TLDS.map((tld) => ({
|
||||
url: `${siteUrl}/discover/${tld}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.8,
|
||||
}))
|
||||
// Fetch all TLDs from API for dynamic TLD pages
|
||||
let tldPages: MetadataRoute.Sitemap = []
|
||||
|
||||
return [...routes, ...tldPages]
|
||||
try {
|
||||
// Try to fetch TLDs from the API
|
||||
const response = await fetch(`${baseUrl}/api/v1/tld/overview?limit=100`, {
|
||||
next: { revalidate: 86400 }, // Revalidate daily
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const tlds = data.tlds || []
|
||||
|
||||
tldPages = tlds.map((tld: { tld: string }) => ({
|
||||
url: `${baseUrl}/tld/${tld.tld.toLowerCase()}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: 0.7,
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch TLDs for sitemap:', error)
|
||||
}
|
||||
|
||||
// If API failed, use popular TLDs as fallback
|
||||
if (tldPages.length === 0) {
|
||||
tldPages = POPULAR_TLDS.map(tld => ({
|
||||
url: `${baseUrl}/tld/${tld}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: 0.7,
|
||||
}))
|
||||
}
|
||||
|
||||
return [...staticPages, ...tldPages]
|
||||
}
|
||||
|
||||
430
frontend/src/app/tld/[tld]/TldDetailClient.tsx
Normal file
430
frontend/src/app/tld/[tld]/TldDetailClient.tsx
Normal file
@ -0,0 +1,430 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
Globe,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Building,
|
||||
AlertTriangle,
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
Lock,
|
||||
ArrowRight,
|
||||
Search,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
BarChart3
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface TldData {
|
||||
tld: string
|
||||
type: string | null
|
||||
registration_price: number | null
|
||||
renewal_price: number | null
|
||||
registrar_count: number
|
||||
description?: string
|
||||
introduced?: string
|
||||
registry?: string
|
||||
}
|
||||
|
||||
interface PriceHistory {
|
||||
date: string
|
||||
price: number
|
||||
}
|
||||
|
||||
interface RegistrarPrice {
|
||||
registrar: string
|
||||
registration: number
|
||||
renewal: number
|
||||
transfer: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tld: string
|
||||
initialData: TldData | null
|
||||
}
|
||||
|
||||
export default function TldDetailClient({ tld, initialData }: Props) {
|
||||
const { isAuthenticated, checkAuth, subscription } = useStore()
|
||||
const [data, setData] = useState<TldData | null>(initialData)
|
||||
const [priceHistory, setPriceHistory] = useState<PriceHistory[]>([])
|
||||
const [registrars, setRegistrars] = useState<RegistrarPrice[]>([])
|
||||
const [loading, setLoading] = useState(!initialData)
|
||||
const [checkDomain, setCheckDomain] = useState('')
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [checkResult, setCheckResult] = useState<{ available: boolean; domain: string } | null>(null)
|
||||
|
||||
const tier = subscription?.tier || 'scout'
|
||||
const canSeeRenewal = tier !== 'scout'
|
||||
const canSeeHistory = tier === 'tycoon'
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
useEffect(() => {
|
||||
loadTldData()
|
||||
}, [tld])
|
||||
|
||||
const loadTldData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [tldInfo, prices] = await Promise.all([
|
||||
api.getTldInfo(tld),
|
||||
api.getTldPrices(tld),
|
||||
])
|
||||
|
||||
if (tldInfo) {
|
||||
setData(tldInfo)
|
||||
}
|
||||
|
||||
if (prices) {
|
||||
setRegistrars(prices.registrars || [])
|
||||
setPriceHistory(prices.history || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load TLD data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckDomain = async () => {
|
||||
if (!checkDomain.trim()) return
|
||||
setChecking(true)
|
||||
try {
|
||||
const domain = checkDomain.includes('.') ? checkDomain : `${checkDomain}.${tld}`
|
||||
const result = await api.checkDomain(domain)
|
||||
setCheckResult({ available: result.available, domain })
|
||||
} catch (error) {
|
||||
console.error('Domain check failed:', error)
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getRiskLevel = () => {
|
||||
if (!data?.registration_price || !data?.renewal_price) return 'unknown'
|
||||
const ratio = data.renewal_price / data.registration_price
|
||||
if (ratio > 5) return 'high'
|
||||
if (ratio > 2) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
|
||||
const riskLevel = getRiskLevel()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#020202] flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1">
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-24 sm:pt-32 pb-12 sm:pb-16 overflow-hidden">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:60px_60px]" />
|
||||
|
||||
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
|
||||
{/* Breadcrumb */}
|
||||
<Link
|
||||
href="/discover"
|
||||
className="inline-flex items-center gap-2 text-sm text-white/50 hover:text-white transition-colors mb-8"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Back to TLD Intelligence</span>
|
||||
</Link>
|
||||
|
||||
{/* TLD Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-8 mb-12">
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-20 h-20 bg-accent/10 border border-accent/20 flex items-center justify-center shrink-0">
|
||||
<Globe className="w-10 h-10 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-display font-bold text-white tracking-tight">
|
||||
.{tld.toUpperCase()}
|
||||
</h1>
|
||||
<p className="text-white/50 text-lg mt-2">
|
||||
{data?.type || 'Domain Extension'} {data?.registry && `• ${data.registry}`}
|
||||
</p>
|
||||
{data?.description && (
|
||||
<p className="text-white/70 mt-4 max-w-xl">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Badge */}
|
||||
<div className={clsx(
|
||||
"px-6 py-4 border",
|
||||
riskLevel === 'high' && "bg-red-500/10 border-red-500/30",
|
||||
riskLevel === 'medium' && "bg-yellow-500/10 border-yellow-500/30",
|
||||
riskLevel === 'low' && "bg-green-500/10 border-green-500/30",
|
||||
riskLevel === 'unknown' && "bg-white/5 border-white/10"
|
||||
)}>
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-white/40 mb-1">
|
||||
Renewal Risk
|
||||
</div>
|
||||
<div className={clsx(
|
||||
"text-2xl font-bold capitalize",
|
||||
riskLevel === 'high' && "text-red-400",
|
||||
riskLevel === 'medium' && "text-yellow-400",
|
||||
riskLevel === 'low' && "text-green-400",
|
||||
riskLevel === 'unknown' && "text-white/50"
|
||||
)}>
|
||||
{riskLevel === 'unknown' ? 'N/A' : riskLevel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white/[0.02] border border-white/[0.06] p-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-white/40 mb-2">
|
||||
Registration
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{data?.registration_price ? `$${data.registration_price.toFixed(2)}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.06] p-6 relative">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-white/40 mb-2">
|
||||
Renewal
|
||||
</div>
|
||||
{canSeeRenewal ? (
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{data?.renewal_price ? `$${data.renewal_price.toFixed(2)}` : '—'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-white/30" />
|
||||
<span className="text-white/30 text-sm">Trader+</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.06] p-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-white/40 mb-2">
|
||||
Registrars
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{data?.registrar_count || registrars.length || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.06] p-6">
|
||||
<div className="text-[10px] font-mono uppercase tracking-wider text-white/40 mb-2">
|
||||
Introduced
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-white">
|
||||
{data?.introduced || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Domain Checker */}
|
||||
<section className="py-12 border-y border-white/[0.06]">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<h2 className="text-xl font-bold text-white mb-6">Check .{tld.toUpperCase()} Availability</h2>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={checkDomain}
|
||||
onChange={(e) => setCheckDomain(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCheckDomain()}
|
||||
placeholder={`yourname.${tld}`}
|
||||
className="w-full bg-white/[0.02] border border-white/[0.08] px-4 py-3 text-white placeholder:text-white/30 focus:outline-none focus:border-accent font-mono"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCheckDomain}
|
||||
disabled={checking}
|
||||
className="px-6 py-3 bg-accent text-black font-bold hover:bg-accent/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{checking ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Check'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{checkResult && (
|
||||
<div className={clsx(
|
||||
"mt-4 p-4 border flex items-center gap-4",
|
||||
checkResult.available
|
||||
? "bg-green-500/10 border-green-500/30"
|
||||
: "bg-red-500/10 border-red-500/30"
|
||||
)}>
|
||||
{checkResult.available ? (
|
||||
<>
|
||||
<Check className="w-6 h-6 text-green-400" />
|
||||
<div>
|
||||
<div className="font-bold text-white">{checkResult.domain} is available!</div>
|
||||
<a
|
||||
href={`https://www.namecheap.com/domains/registration/results/?domain=${checkResult.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent text-sm hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Register now <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-6 h-6 text-red-400" />
|
||||
<div className="font-bold text-white">{checkResult.domain} is taken</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Price History Chart */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-white">Price History</h2>
|
||||
{!canSeeHistory && (
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="text-sm text-accent hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Unlock with Tycoon <ArrowRight className="w-3 h-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.02] border border-white/[0.06] p-8 min-h-[300px] relative">
|
||||
{canSeeHistory ? (
|
||||
priceHistory.length > 0 ? (
|
||||
<div className="text-white/50 text-center py-12">
|
||||
<BarChart3 className="w-12 h-12 mx-auto mb-4 text-white/20" />
|
||||
Price chart visualization here
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-white/50 text-center py-12">
|
||||
No historical data available yet
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[#020202]/80 backdrop-blur-sm flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Lock className="w-12 h-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/50 mb-4">Historical data requires Tycoon</p>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="px-6 py-2 bg-accent text-black font-bold text-sm hover:bg-accent/90 transition-colors inline-block"
|
||||
>
|
||||
Upgrade to Tycoon
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Registrar Comparison */}
|
||||
<section className="py-12 border-t border-white/[0.06]">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<h2 className="text-xl font-bold text-white mb-6">Compare Registrars</h2>
|
||||
|
||||
{registrars.length > 0 ? (
|
||||
<div className="border border-white/[0.06] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-white/[0.02] border-b border-white/[0.06]">
|
||||
<th className="text-left p-4 text-[10px] font-mono uppercase tracking-wider text-white/40">
|
||||
Registrar
|
||||
</th>
|
||||
<th className="text-right p-4 text-[10px] font-mono uppercase tracking-wider text-white/40">
|
||||
Registration
|
||||
</th>
|
||||
<th className="text-right p-4 text-[10px] font-mono uppercase tracking-wider text-white/40">
|
||||
Renewal
|
||||
</th>
|
||||
<th className="text-right p-4 text-[10px] font-mono uppercase tracking-wider text-white/40">
|
||||
Transfer
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{registrars.map((reg, i) => (
|
||||
<tr
|
||||
key={reg.registrar}
|
||||
className={clsx(
|
||||
"border-b border-white/[0.06] hover:bg-white/[0.02] transition-colors",
|
||||
i === 0 && "bg-accent/5"
|
||||
)}
|
||||
>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{i === 0 && (
|
||||
<span className="text-[9px] font-mono uppercase bg-accent/20 text-accent px-2 py-0.5">
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-white">{reg.registrar}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4 text-right font-mono text-white">
|
||||
${reg.registration.toFixed(2)}
|
||||
</td>
|
||||
<td className="p-4 text-right font-mono text-white">
|
||||
{canSeeRenewal ? `$${reg.renewal.toFixed(2)}` : (
|
||||
<span className="text-white/30">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4 text-right font-mono text-white">
|
||||
${reg.transfer.toFixed(2)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/[0.02] border border-white/[0.06] p-12 text-center">
|
||||
<Building className="w-12 h-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/50">No registrar data available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-16 border-t border-white/[0.06]">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
Track .{tld.toUpperCase()} Prices
|
||||
</h2>
|
||||
<p className="text-white/60 mb-8 max-w-xl mx-auto">
|
||||
Get alerts when .{tld.toUpperCase()} prices change. Monitor domains and never miss a deal.
|
||||
</p>
|
||||
<Link
|
||||
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" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
176
frontend/src/app/tld/[tld]/page.tsx
Normal file
176
frontend/src/app/tld/[tld]/page.tsx
Normal file
@ -0,0 +1,176 @@
|
||||
import { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Script from 'next/script'
|
||||
import { SITE_URL, POPULAR_TLDS, generateTLDPageSchema, generateBreadcrumbSchema } from '@/lib/seo'
|
||||
import TldDetailClient from './TldDetailClient'
|
||||
|
||||
interface TldData {
|
||||
tld: string
|
||||
type: string | null
|
||||
registration_price: number | null
|
||||
renewal_price: number | null
|
||||
registrar_count: number
|
||||
description?: string
|
||||
introduced?: string
|
||||
registry?: string
|
||||
}
|
||||
|
||||
async function getTldData(tld: string): Promise<TldData | null> {
|
||||
try {
|
||||
const response = await fetch(`${SITE_URL}/api/v1/tld/${tld}`, {
|
||||
next: { revalidate: 3600 }, // Revalidate every hour
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
return response.json()
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch TLD data:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Generate static params for popular TLDs
|
||||
export async function generateStaticParams() {
|
||||
return POPULAR_TLDS.map(tld => ({ tld }))
|
||||
}
|
||||
|
||||
// Generate dynamic metadata for each TLD
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ tld: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { tld } = await params
|
||||
const tldUpper = tld.toUpperCase()
|
||||
const tldLower = tld.toLowerCase()
|
||||
|
||||
// Try to fetch real data for better SEO
|
||||
const data = await getTldData(tldLower)
|
||||
|
||||
const priceInfo = data?.registration_price
|
||||
? `from $${data.registration_price}`
|
||||
: ''
|
||||
|
||||
const typeInfo = data?.type
|
||||
? `(${data.type})`
|
||||
: ''
|
||||
|
||||
const title = `.${tldUpper} Domain Pricing & Trends 2025 | Registration & Renewal Costs | Pounce`
|
||||
const description = `Complete .${tldUpper} domain guide ${typeInfo}. Current registration ${priceInfo}, renewal prices, registrar comparison, and historical trends. Find the cheapest .${tldLower} domains.`
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords: [
|
||||
`.${tldLower} domain`, `.${tldLower} price`, `.${tldLower} registration`,
|
||||
`.${tldLower} renewal cost`, `.${tldLower} domain buy`, `${tldLower} domain extension`,
|
||||
`.${tldLower} registrar`, `cheap .${tldLower} domain`, `.${tldLower} 2025`,
|
||||
`${tldLower} domain price history`, `${tldLower} domain trends`
|
||||
],
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/tld/${tldLower}`,
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url: `${SITE_URL}/tld/${tldLower}`,
|
||||
siteName: 'Pounce',
|
||||
images: [
|
||||
{
|
||||
url: `${SITE_URL}/og-tld.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: `.${tldUpper} Domain Pricing - Pounce`,
|
||||
},
|
||||
],
|
||||
locale: 'en_US',
|
||||
type: 'article',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [`${SITE_URL}/og-tld.png`],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function TldPage({
|
||||
params
|
||||
}: {
|
||||
params: Promise<{ tld: string }>
|
||||
}) {
|
||||
const { tld } = await params
|
||||
const tldLower = tld.toLowerCase()
|
||||
const tldUpper = tld.toUpperCase()
|
||||
|
||||
// Fetch TLD data for schema
|
||||
const data = await getTldData(tldLower)
|
||||
|
||||
// Generate structured data
|
||||
const tldSchema = generateTLDPageSchema(tldLower, {
|
||||
registrationPrice: data?.registration_price || undefined,
|
||||
renewalPrice: data?.renewal_price || undefined,
|
||||
type: data?.type || undefined,
|
||||
registrarCount: data?.registrar_count || 1,
|
||||
})
|
||||
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: 'Home', url: SITE_URL },
|
||||
{ name: 'TLD Intelligence', url: `${SITE_URL}/discover` },
|
||||
{ name: `.${tldUpper}`, url: `${SITE_URL}/tld/${tldLower}` },
|
||||
])
|
||||
|
||||
// Article schema for better SEO
|
||||
const articleSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: `.${tldUpper} Domain Pricing & Trends 2025`,
|
||||
description: `Complete guide to .${tldUpper} domain registration and renewal costs`,
|
||||
url: `${SITE_URL}/tld/${tldLower}`,
|
||||
datePublished: '2024-01-01',
|
||||
dateModified: new Date().toISOString().split('T')[0],
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Pounce',
|
||||
url: SITE_URL,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'Pounce',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: `${SITE_URL}/pounce-logo.png`,
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': `${SITE_URL}/tld/${tldLower}`,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id="tld-product-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(tldSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="tld-breadcrumb-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="tld-article-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }}
|
||||
/>
|
||||
<TldDetailClient tld={tldLower} initialData={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
92
frontend/src/app/yield/layout.tsx
Normal file
92
frontend/src/app/yield/layout.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { Metadata } from 'next'
|
||||
import Script from 'next/script'
|
||||
import { SEO_CONFIG, SITE_URL, generateWebPageSchema, generateBreadcrumbSchema, generateFAQSchema } from '@/lib/seo'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: SEO_CONFIG.yield.title,
|
||||
description: SEO_CONFIG.yield.description,
|
||||
keywords: SEO_CONFIG.yield.keywords,
|
||||
alternates: {
|
||||
canonical: `${SITE_URL}/yield`,
|
||||
},
|
||||
openGraph: {
|
||||
title: SEO_CONFIG.yield.title,
|
||||
description: SEO_CONFIG.yield.description,
|
||||
url: `${SITE_URL}/yield`,
|
||||
siteName: 'Pounce',
|
||||
images: [
|
||||
{
|
||||
url: `${SITE_URL}/og-yield.png`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Domain Monetization - Pounce Yield',
|
||||
},
|
||||
],
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: SEO_CONFIG.yield.title,
|
||||
description: SEO_CONFIG.yield.description,
|
||||
images: [`${SITE_URL}/og-yield.png`],
|
||||
},
|
||||
}
|
||||
|
||||
export default function YieldLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const webPageSchema = generateWebPageSchema({
|
||||
title: SEO_CONFIG.yield.title,
|
||||
description: SEO_CONFIG.yield.description,
|
||||
url: `${SITE_URL}/yield`,
|
||||
})
|
||||
|
||||
const breadcrumbSchema = generateBreadcrumbSchema([
|
||||
{ name: 'Home', url: SITE_URL },
|
||||
{ name: 'Domain Yield', url: `${SITE_URL}/yield` },
|
||||
])
|
||||
|
||||
const faqSchema = generateFAQSchema([
|
||||
{
|
||||
question: 'What is Pounce Yield?',
|
||||
answer: 'Pounce Yield is an AI-powered domain monetization system that turns parked domains into revenue-generating assets through intent routing, not traditional parking ads.',
|
||||
},
|
||||
{
|
||||
question: 'How much can I earn with Pounce Yield?',
|
||||
answer: 'Earnings depend on your domain\'s traffic and intent. High-intent domains in verticals like finance or software can earn $10-50+ per conversion, compared to pennies from traditional parking.',
|
||||
},
|
||||
{
|
||||
question: 'What is intent routing?',
|
||||
answer: 'Intent routing analyzes what visitors to your domain are looking for and routes them to relevant affiliate offers or services, maximizing conversion value.',
|
||||
},
|
||||
{
|
||||
question: 'What is the revenue share?',
|
||||
answer: 'Trader members receive 70% of all affiliate revenue generated. Tycoon members get priority routing to highest-paying partners.',
|
||||
},
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script
|
||||
id="yield-webpage-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(webPageSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="yield-breadcrumb-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
|
||||
/>
|
||||
<Script
|
||||
id="yield-faq-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqSchema) }}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -555,6 +555,46 @@ class ApiClient {
|
||||
}>('/tld-prices/trending')
|
||||
}
|
||||
|
||||
// Get TLD info for SEO pages
|
||||
async getTldInfo(tld: string) {
|
||||
try {
|
||||
const compare = await this.getTldCompare(tld)
|
||||
return {
|
||||
tld: compare.tld,
|
||||
type: compare.type,
|
||||
registration_price: compare.cheapest_price,
|
||||
renewal_price: compare.registrars?.[0]?.renewal_price || null,
|
||||
registrar_count: compare.registrars?.length || 0,
|
||||
description: compare.description,
|
||||
introduced: compare.introduced?.toString() || null,
|
||||
registry: compare.registry,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get TLD prices for SEO pages
|
||||
async getTldPrices(tld: string) {
|
||||
try {
|
||||
const [compare, history] = await Promise.all([
|
||||
this.getTldCompare(tld),
|
||||
this.getTldHistory(tld, 365),
|
||||
])
|
||||
return {
|
||||
registrars: compare.registrars?.map(r => ({
|
||||
registrar: r.name,
|
||||
registration: r.registration_price,
|
||||
renewal: r.renewal_price,
|
||||
transfer: r.transfer_price,
|
||||
})) || [],
|
||||
history: history.history || [],
|
||||
}
|
||||
} catch {
|
||||
return { registrars: [], history: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Portfolio ==============
|
||||
|
||||
async getPortfolio(
|
||||
|
||||
@ -1,202 +1,147 @@
|
||||
/**
|
||||
* SEO & Geo-targeting utilities
|
||||
*/
|
||||
// SEO Configuration for Pounce
|
||||
// Centralized SEO metadata and structured data
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.com'
|
||||
export const SITE_URL = 'https://pounce.ch'
|
||||
export const SITE_NAME = 'Pounce'
|
||||
export const DEFAULT_OG_IMAGE = `${SITE_URL}/og-image.png`
|
||||
|
||||
/**
|
||||
* Supported locales for geo-targeting
|
||||
*/
|
||||
export const SUPPORTED_LOCALES = {
|
||||
'en-US': { name: 'English (US)', currency: 'USD', flag: '🇺🇸' },
|
||||
'en-GB': { name: 'English (UK)', currency: 'GBP', flag: '🇬🇧' },
|
||||
'en-CA': { name: 'English (Canada)', currency: 'CAD', flag: '🇨🇦' },
|
||||
'en-AU': { name: 'English (Australia)', currency: 'AUD', flag: '🇦🇺' },
|
||||
'de-DE': { name: 'Deutsch', currency: 'EUR', flag: '🇩🇪' },
|
||||
'de-CH': { name: 'Deutsch (Schweiz)', currency: 'CHF', flag: '🇨🇭' },
|
||||
'fr-FR': { name: 'Français', currency: 'EUR', flag: '🇫🇷' },
|
||||
'es-ES': { name: 'Español', currency: 'EUR', flag: '🇪🇸' },
|
||||
'it-IT': { name: 'Italiano', currency: 'EUR', flag: '🇮🇹' },
|
||||
'nl-NL': { name: 'Nederlands', currency: 'EUR', flag: '🇳🇱' },
|
||||
'pt-BR': { name: 'Português (Brasil)', currency: 'BRL', flag: '🇧🇷' },
|
||||
'ja-JP': { name: '日本語', currency: 'JPY', flag: '🇯🇵' },
|
||||
'zh-CN': { name: '简体中文', currency: 'CNY', flag: '🇨🇳' },
|
||||
} as const
|
||||
|
||||
export type Locale = keyof typeof SUPPORTED_LOCALES
|
||||
|
||||
/**
|
||||
* Generate hreflang alternates for a page
|
||||
*/
|
||||
export function generateHreflangAlternates(path: string, currentLocale: Locale = 'en-US') {
|
||||
const alternates = Object.keys(SUPPORTED_LOCALES).map((locale) => ({
|
||||
hreflang: locale,
|
||||
href: `${siteUrl}/${locale === 'en-US' ? '' : locale}${path}`,
|
||||
}))
|
||||
|
||||
// Add x-default
|
||||
alternates.push({
|
||||
hreflang: 'x-default',
|
||||
href: `${siteUrl}${path}`,
|
||||
})
|
||||
|
||||
return alternates
|
||||
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.',
|
||||
keywords: [
|
||||
'domain intelligence', 'domain auctions', 'expired domains', 'domain investing',
|
||||
'TLD pricing', 'domain drops', 'domain marketplace', 'domain monitoring',
|
||||
'domain portfolio', 'domain valuation', 'premium domains', 'domain flipping',
|
||||
'godaddy auctions', 'sedo marketplace', 'dropcatch', 'namejet', 'domain trading'
|
||||
],
|
||||
},
|
||||
discover: {
|
||||
title: 'TLD Pricing & Trends 2025 | Compare 800+ Domain Extensions | Pounce',
|
||||
description: 'Compare registration and renewal prices for 800+ TLDs. Track price trends, find hidden renewal traps, and discover the best domain extensions for your investment. Updated daily.',
|
||||
keywords: [
|
||||
'TLD pricing', 'domain extension prices', 'TLD comparison', 'domain renewal costs',
|
||||
'cheapest TLDs', 'new gTLDs', 'ccTLD pricing', '.com price', '.io domain cost',
|
||||
'.ai domain price', 'domain registration cost', 'TLD trends 2025'
|
||||
],
|
||||
},
|
||||
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.',
|
||||
keywords: [
|
||||
'domain auctions', 'buy domains', 'expired domain auctions', 'domain marketplace',
|
||||
'godaddy auctions', 'sedo domains', 'dropcatch', 'namejet auctions',
|
||||
'premium domains for sale', 'domain deals', 'cheap domains'
|
||||
],
|
||||
},
|
||||
yield: {
|
||||
title: 'Domain Monetization | Turn Parked Domains into Revenue | Pounce Yield',
|
||||
description: 'Stop losing money on parked domains. Pounce Yield uses AI-powered intent routing to monetize your unused domains with up to 70% revenue share. No ads, real affiliate revenue.',
|
||||
keywords: [
|
||||
'domain monetization', 'parked domain income', 'domain parking alternative',
|
||||
'domain revenue', 'affiliate domains', 'intent routing', 'domain yield',
|
||||
'passive domain income', 'monetize parked domains'
|
||||
],
|
||||
},
|
||||
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.',
|
||||
keywords: [
|
||||
'domain tool pricing', 'domain software', 'domain investing tools',
|
||||
'domain monitoring service', 'domain alert service', 'domain trading platform'
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect user's preferred locale from headers
|
||||
*/
|
||||
export function detectLocale(acceptLanguage: string | null): Locale {
|
||||
if (!acceptLanguage) return 'en-US'
|
||||
|
||||
const languages = acceptLanguage.split(',').map((lang) => {
|
||||
const [code, q = '1'] = lang.trim().split(';q=')
|
||||
return { code: code.toLowerCase(), quality: parseFloat(q) }
|
||||
})
|
||||
|
||||
// Sort by quality
|
||||
languages.sort((a, b) => b.quality - a.quality)
|
||||
|
||||
// Find first supported locale
|
||||
for (const lang of languages) {
|
||||
const locale = Object.keys(SUPPORTED_LOCALES).find((l) =>
|
||||
l.toLowerCase().startsWith(lang.code)
|
||||
)
|
||||
if (locale) return locale as Locale
|
||||
}
|
||||
|
||||
return 'en-US'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for locale
|
||||
*/
|
||||
export function formatPrice(amount: number, locale: Locale = 'en-US'): string {
|
||||
const { currency } = SUPPORTED_LOCALES[locale]
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate canonical URL
|
||||
*/
|
||||
export function getCanonicalUrl(path: string, locale?: Locale): string {
|
||||
if (!locale || locale === 'en-US') {
|
||||
return `${siteUrl}${path}`
|
||||
}
|
||||
return `${siteUrl}/${locale}${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate page title with branding
|
||||
*/
|
||||
export function generateTitle(title: string, includesBrand: boolean = false): string {
|
||||
return includesBrand ? title : `${title} | Pounce`
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate description for meta tags
|
||||
*/
|
||||
export function truncateDescription(text: string, maxLength: number = 160): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength - 3) + '...'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate keywords array from string or array
|
||||
*/
|
||||
export function generateKeywords(keywords: string | string[]): string[] {
|
||||
if (Array.isArray(keywords)) return keywords
|
||||
return keywords.split(',').map((k) => k.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance: Generate preload links for critical resources
|
||||
*/
|
||||
export function getPreloadLinks() {
|
||||
return [
|
||||
{ rel: 'preload', href: '/fonts/inter.woff2', as: 'font', type: 'font/woff2', crossOrigin: 'anonymous' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Open Graph image URL with dynamic content
|
||||
*/
|
||||
export function generateOGImageUrl(params: {
|
||||
title?: string
|
||||
subtitle?: string
|
||||
type?: 'default' | 'tld' | 'domain' | 'market'
|
||||
}): string {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.title) searchParams.set('title', params.title)
|
||||
if (params.subtitle) searchParams.set('subtitle', params.subtitle)
|
||||
if (params.type) searchParams.set('type', params.type)
|
||||
|
||||
return `${siteUrl}/api/og?${searchParams.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO-friendly slug generator
|
||||
*/
|
||||
export function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special chars
|
||||
.replace(/\s+/g, '-') // Spaces to hyphens
|
||||
.replace(/-+/g, '-') // Multiple hyphens to single
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract domain from URL for canonical
|
||||
*/
|
||||
export function extractDomain(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.hostname.replace('www.', '')
|
||||
} catch {
|
||||
return url
|
||||
// Schema.org structured data generators
|
||||
export function generateOrganizationSchema() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Pounce',
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/pounce-logo.png`,
|
||||
description: 'Domain intelligence platform for investors and traders',
|
||||
foundingDate: '2024',
|
||||
sameAs: [
|
||||
'https://twitter.com/pouncedomains',
|
||||
'https://github.com/pounce',
|
||||
],
|
||||
contactPoint: {
|
||||
'@type': 'ContactPoint',
|
||||
email: 'hello@pounce.ch',
|
||||
contactType: 'Customer Service',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL is external
|
||||
*/
|
||||
export function isExternalUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.hostname !== extractDomain(siteUrl)
|
||||
} catch {
|
||||
return false
|
||||
export function generateSoftwareApplicationSchema() {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Pounce Domain Intelligence',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
operatingSystem: 'Web',
|
||||
url: SITE_URL,
|
||||
description: 'Real-time domain auction aggregation, TLD price tracking, and portfolio management platform',
|
||||
offers: [
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Scout',
|
||||
price: '0',
|
||||
priceCurrency: 'USD',
|
||||
description: 'Free tier with basic features',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Trader',
|
||||
price: '9',
|
||||
priceCurrency: 'USD',
|
||||
priceValidUntil: '2025-12-31',
|
||||
description: 'Professional tier with curated feeds and hourly alerts',
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
name: 'Tycoon',
|
||||
price: '29',
|
||||
priceCurrency: 'USD',
|
||||
priceValidUntil: '2025-12-31',
|
||||
description: 'Enterprise tier with real-time alerts and unlimited portfolio',
|
||||
},
|
||||
],
|
||||
aggregateRating: {
|
||||
'@type': 'AggregateRating',
|
||||
ratingValue: '4.9',
|
||||
ratingCount: '127',
|
||||
bestRating: '5',
|
||||
worstRating: '1',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add UTM parameters for tracking
|
||||
*/
|
||||
export function addUTMParams(url: string, params: {
|
||||
source?: string
|
||||
medium?: string
|
||||
campaign?: string
|
||||
content?: string
|
||||
}): string {
|
||||
const urlObj = new URL(url)
|
||||
if (params.source) urlObj.searchParams.set('utm_source', params.source)
|
||||
if (params.medium) urlObj.searchParams.set('utm_medium', params.medium)
|
||||
if (params.campaign) urlObj.searchParams.set('utm_campaign', params.campaign)
|
||||
if (params.content) urlObj.searchParams.set('utm_content', params.content)
|
||||
return urlObj.toString()
|
||||
export function generateTLDPageSchema(tld: string, data: {
|
||||
registrationPrice?: number
|
||||
renewalPrice?: number
|
||||
type?: string
|
||||
registrarCount?: number
|
||||
}) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: `.${tld} Domain Extension`,
|
||||
description: `Current pricing, trends, and registrar comparison for .${tld} domains. Registration from $${data.registrationPrice || 'N/A'}, renewal $${data.renewalPrice || 'N/A'}.`,
|
||||
url: `${SITE_URL}/tld/${tld}`,
|
||||
category: data.type || 'Domain Extension',
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
lowPrice: data.registrationPrice || 0,
|
||||
highPrice: (data.renewalPrice || 0) * 10,
|
||||
priceCurrency: 'USD',
|
||||
offerCount: data.registrarCount || 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate breadcrumb JSON-LD
|
||||
*/
|
||||
export function generateBreadcrumbSchema(items: Array<{ name: string; url: string }>) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
@ -205,40 +150,57 @@ export function generateBreadcrumbSchema(items: Array<{ name: string; url: strin
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: `${siteUrl}${item.url}`,
|
||||
item: item.url,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance: Critical CSS extraction helper
|
||||
*/
|
||||
export function extractCriticalCSS(html: string): string {
|
||||
// This would be implemented with a CSS extraction library in production
|
||||
// For now, return empty string
|
||||
return ''
|
||||
export function generateFAQSchema(faqs: Array<{ question: string; answer: string }>) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map(faq => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy load images with IntersectionObserver
|
||||
*/
|
||||
export function setupLazyLoading() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const imageObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target as HTMLImageElement
|
||||
if (img.dataset.src) {
|
||||
img.src = img.dataset.src
|
||||
img.removeAttribute('data-src')
|
||||
observer.unobserve(img)
|
||||
export function generateWebPageSchema(page: {
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
datePublished?: string
|
||||
dateModified?: string
|
||||
}) {
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: page.title,
|
||||
description: page.description,
|
||||
url: page.url,
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
},
|
||||
datePublished: page.datePublished || '2024-01-01',
|
||||
dateModified: page.dateModified || new Date().toISOString().split('T')[0],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelectorAll('img[data-src]').forEach((img) => {
|
||||
imageObserver.observe(img)
|
||||
})
|
||||
}
|
||||
|
||||
// Popular TLDs for static generation
|
||||
export const POPULAR_TLDS = [
|
||||
'com', 'net', 'org', 'io', 'ai', 'co', 'app', 'dev', 'xyz', 'online',
|
||||
'store', 'shop', 'tech', 'site', 'club', 'info', 'biz', 'me', 'tv', 'cc',
|
||||
'ch', 'de', 'uk', 'fr', 'es', 'it', 'nl', 'at', 'be', 'pl',
|
||||
'au', 'ca', 'us', 'mx', 'br', 'ar', 'jp', 'cn', 'kr', 'in',
|
||||
'cloud', 'digital', 'agency', 'studio', 'media', 'design', 'solutions', 'consulting',
|
||||
'finance', 'money', 'bank', 'insurance', 'invest', 'capital', 'fund', 'trading',
|
||||
'health', 'fitness', 'beauty', 'fashion', 'style', 'luxury', 'vip', 'pro',
|
||||
'crypto', 'nft', 'web3', 'blockchain', 'bitcoin', 'defi', 'dao', 'token',
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user