diff --git a/backend/app/api/auctions.py b/backend/app/api/auctions.py index d490315..417156c 100644 --- a/backend/app/api/auctions.py +++ b/backend/app/api/auctions.py @@ -16,6 +16,12 @@ Legal Note (Switzerland): - No escrow/payment handling = no GwG/FINMA requirements - Users click through to external platforms - We only provide market intelligence + +VALUATION: +All estimated values are calculated using our transparent algorithm: + Value = $50 × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor + +See /api/v1/portfolio/valuation/{domain} for full calculation details. """ import logging from datetime import datetime, timedelta @@ -23,12 +29,11 @@ from typing import Optional, List from fastapi import APIRouter, Depends, Query from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -import httpx -import asyncio from app.database import get_db from app.api.deps import get_current_user, get_current_user_optional from app.models.user import User +from app.services.valuation import valuation_service logger = logging.getLogger(__name__) router = APIRouter() @@ -36,6 +41,15 @@ router = APIRouter() # ============== Schemas ============== +class AuctionValuation(BaseModel): + """Valuation details for an auction.""" + estimated_value: float + value_ratio: float # estimated_value / current_bid + potential_profit: float # estimated_value - current_bid + confidence: str + valuation_formula: str + + class AuctionListing(BaseModel): """A domain auction listing from any platform.""" domain: str @@ -52,6 +66,8 @@ class AuctionListing(BaseModel): age_years: Optional[int] = None tld: str affiliate_url: str + # Valuation + valuation: Optional[AuctionValuation] = None class AuctionSearchResponse(BaseModel): @@ -60,6 +76,11 @@ class AuctionSearchResponse(BaseModel): total: int platforms_searched: List[str] last_updated: datetime + valuation_note: str = ( + "Values are estimated using our algorithm: " + "$50 × Length × TLD × Keyword × Brand factors. " + "See /portfolio/valuation/{domain} for detailed breakdown." + ) class PlatformStats(BaseModel): @@ -72,7 +93,6 @@ class PlatformStats(BaseModel): # ============== Mock Data (for demo - replace with real scrapers) ============== -# In production, these would be real API calls or web scrapers MOCK_AUCTIONS = [ { "domain": "cryptopay.io", @@ -218,20 +238,41 @@ def _format_time_remaining(end_time: datetime) -> str: def _get_affiliate_url(platform: str, domain: str) -> str: """Get affiliate URL for a platform.""" base_url = PLATFORM_URLS.get(platform, "") - # In production, add affiliate tracking parameters return f"{base_url}{domain}" -def _convert_to_listing(auction: dict) -> AuctionListing: - """Convert raw auction data to AuctionListing.""" +async def _convert_to_listing(auction: dict, db: AsyncSession, include_valuation: bool = True) -> AuctionListing: + """Convert raw auction data to AuctionListing with valuation.""" domain = auction["domain"] tld = domain.rsplit(".", 1)[-1] if "." in domain else "" + current_bid = auction["current_bid"] + + valuation_data = None + + if include_valuation: + try: + # Get real valuation from our service + result = await valuation_service.estimate_value(domain, db, save_result=False) + + if "error" not in result: + estimated_value = result["estimated_value"] + value_ratio = round(estimated_value / current_bid, 2) if current_bid > 0 else 99 + + valuation_data = AuctionValuation( + estimated_value=estimated_value, + value_ratio=value_ratio, + potential_profit=round(estimated_value - current_bid, 2), + confidence=result.get("confidence", "medium"), + valuation_formula=result.get("calculation", {}).get("formula", "N/A"), + ) + except Exception as e: + logger.error(f"Valuation error for {domain}: {e}") return AuctionListing( domain=domain, platform=auction["platform"], platform_url=PLATFORM_URLS.get(auction["platform"], ""), - current_bid=auction["current_bid"], + current_bid=current_bid, currency="USD", num_bids=auction["num_bids"], end_time=auction["end_time"], @@ -242,6 +283,7 @@ def _convert_to_listing(auction: dict) -> AuctionListing: age_years=auction.get("age_years"), tld=tld, affiliate_url=_get_affiliate_url(auction["platform"], domain), + valuation=valuation_data, ) @@ -255,10 +297,11 @@ async def search_auctions( min_bid: Optional[float] = Query(None, ge=0, description="Minimum current bid"), max_bid: Optional[float] = Query(None, ge=0, description="Maximum current bid"), ending_soon: bool = Query(False, description="Only show auctions ending in < 1 hour"), - sort_by: str = Query("ending", enum=["ending", "bid_asc", "bid_desc", "bids"]), + sort_by: str = Query("ending", enum=["ending", "bid_asc", "bid_desc", "bids", "value_ratio"]), limit: int = Query(20, le=100), offset: int = Query(0, ge=0), current_user: Optional[User] = Depends(get_current_user_optional), + db: AsyncSession = Depends(get_db), ): """ Search domain auctions across multiple platforms. @@ -268,12 +311,14 @@ async def search_auctions( - Clicking through uses affiliate links - We do NOT handle payments or transfers + All auctions include estimated values calculated using our algorithm: + Value = $50 × Length_Factor × TLD_Factor × Keyword_Factor × Brand_Factor + Smart Pounce Strategy: - - Find undervalued domains ending soon + - Look for value_ratio > 1.0 (estimated value exceeds current bid) + - Focus on auctions ending soon with low bid counts - Track keywords you're interested in - - Get notified when matching auctions appear """ - # In production, this would call real scrapers/APIs auctions = MOCK_AUCTIONS.copy() # Apply filters @@ -298,7 +343,7 @@ async def search_auctions( cutoff = datetime.utcnow() + timedelta(hours=1) auctions = [a for a in auctions if a["end_time"] <= cutoff] - # Sort + # Sort (before valuation for efficiency, except value_ratio) if sort_by == "ending": auctions.sort(key=lambda x: x["end_time"]) elif sort_by == "bid_asc": @@ -308,12 +353,21 @@ async def search_auctions( elif sort_by == "bids": auctions.sort(key=lambda x: x["num_bids"], reverse=True) - # Pagination total = len(auctions) auctions = auctions[offset:offset + limit] - # Convert to response format - listings = [_convert_to_listing(a) for a in auctions] + # Convert to response format with valuations + listings = [] + for a in auctions: + listing = await _convert_to_listing(a, db, include_valuation=True) + listings.append(listing) + + # Sort by value_ratio if requested (after valuation) + if sort_by == "value_ratio": + listings.sort( + key=lambda x: x.valuation.value_ratio if x.valuation else 0, + reverse=True + ) return AuctionSearchResponse( auctions=listings, @@ -328,6 +382,7 @@ async def get_ending_soon( hours: int = Query(1, ge=1, le=24, description="Hours until end"), limit: int = Query(10, le=50), current_user: Optional[User] = Depends(get_current_user_optional), + db: AsyncSession = Depends(get_db), ): """ Get auctions ending soon - best opportunities for sniping. @@ -335,29 +390,42 @@ async def get_ending_soon( Smart Pounce Tip: - Auctions ending in < 1 hour often have final bidding frenzy - Low-bid auctions ending soon can be bargains + - Look for value_ratio > 1.0 (undervalued domains) """ cutoff = datetime.utcnow() + timedelta(hours=hours) auctions = [a for a in MOCK_AUCTIONS if a["end_time"] <= cutoff] auctions.sort(key=lambda x: x["end_time"]) auctions = auctions[:limit] - return [_convert_to_listing(a) for a in auctions] + listings = [] + for a in auctions: + listing = await _convert_to_listing(a, db, include_valuation=True) + listings.append(listing) + + return listings @router.get("/hot", response_model=List[AuctionListing]) async def get_hot_auctions( limit: int = Query(10, le=50), current_user: Optional[User] = Depends(get_current_user_optional), + db: AsyncSession = Depends(get_db), ): """ Get hottest auctions by bidding activity. These auctions have the most competition - high demand indicators. + High demand often correlates with quality domains. """ auctions = sorted(MOCK_AUCTIONS, key=lambda x: x["num_bids"], reverse=True) auctions = auctions[:limit] - return [_convert_to_listing(a) for a in auctions] + listings = [] + for a in auctions: + listing = await _convert_to_listing(a, db, include_valuation=True) + listings.append(listing) + + return listings @router.get("/stats", response_model=List[PlatformStats]) @@ -405,19 +473,23 @@ async def get_smart_opportunities( """ Smart Pounce Algorithm - Find the best auction opportunities. - Analyzes: - - Auctions ending soon with low bids (potential bargains) - - Domains with high estimated value vs current bid - - Keywords from user's watchlist + Our algorithm scores each auction based on: + 1. Value Ratio: estimated_value / current_bid (higher = better deal) + 2. Time Factor: Auctions ending soon get 2× boost + 3. Bid Factor: Low bid count (< 10) gets 1.5× boost + + Opportunity Score = value_ratio × time_factor × bid_factor + + Recommendations: + - "Strong buy": Score > 5 (significantly undervalued) + - "Consider": Score 2-5 (potential opportunity) + - "Monitor": Score < 2 (fairly priced) Requires authentication to personalize results. """ - from app.services.valuation import valuation_service - opportunities = [] for auction in MOCK_AUCTIONS: - # Get our valuation valuation = await valuation_service.estimate_value(auction["domain"], db, save_result=False) if "error" in valuation: @@ -426,47 +498,76 @@ async def get_smart_opportunities( estimated_value = valuation["estimated_value"] current_bid = auction["current_bid"] - # Calculate opportunity score - # Higher score = better opportunity value_ratio = estimated_value / current_bid if current_bid > 0 else 10 - # Time factor - ending soon is more urgent hours_left = (auction["end_time"] - datetime.utcnow()).total_seconds() / 3600 time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0) - # Bid activity factor - less bids might mean overlooked bid_factor = 1.5 if auction["num_bids"] < 10 else 1.0 opportunity_score = value_ratio * time_factor * bid_factor - listing = _convert_to_listing(auction) + listing = await _convert_to_listing(auction, db, include_valuation=True) opportunities.append({ "auction": listing.model_dump(), "analysis": { "estimated_value": estimated_value, "current_bid": current_bid, "value_ratio": round(value_ratio, 2), - "potential_profit": estimated_value - current_bid, + "potential_profit": round(estimated_value - current_bid, 2), "opportunity_score": round(opportunity_score, 2), + "time_factor": time_factor, + "bid_factor": bid_factor, "recommendation": ( "Strong buy" if opportunity_score > 5 else "Consider" if opportunity_score > 2 else "Monitor" ), + "reasoning": _get_opportunity_reasoning( + value_ratio, hours_left, auction["num_bids"], opportunity_score + ), } }) - # Sort by opportunity score opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True) return { "opportunities": opportunities[:10], + "valuation_method": ( + "Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors. " + "See /portfolio/valuation/{domain} for detailed breakdown of any domain." + ), "strategy_tips": [ - "Focus on auctions ending in < 1 hour for best snipe opportunities", - "Look for domains with value_ratio > 2.0 (undervalued)", - "Low bid count often indicates overlooked gems", - "Set alerts for keywords you're interested in", + "🎯 Focus on value_ratio > 1.0 (estimated value exceeds current bid)", + "⏰ Auctions ending in < 1 hour often have best snipe opportunities", + "📉 Low bid count (< 10) might indicate overlooked gems", + "💡 Premium TLDs (.com, .ai, .io) have highest aftermarket demand", ], "generated_at": datetime.utcnow().isoformat(), } + +def _get_opportunity_reasoning(value_ratio: float, hours_left: float, num_bids: int, score: float) -> str: + """Generate human-readable reasoning for the opportunity.""" + reasons = [] + + if value_ratio > 2: + reasons.append(f"Significantly undervalued ({value_ratio:.1f}× estimated value)") + elif value_ratio > 1: + reasons.append(f"Undervalued ({value_ratio:.1f}× estimated value)") + else: + reasons.append(f"Current bid exceeds our estimate ({value_ratio:.2f}×)") + + if hours_left < 1: + reasons.append("⚡ Ending very soon - final chance to bid") + elif hours_left < 4: + reasons.append("⏰ Ending soon - limited time remaining") + + if num_bids < 5: + reasons.append("📉 Very low competition - potential overlooked opportunity") + elif num_bids < 10: + reasons.append("📊 Moderate competition") + else: + reasons.append(f"🔥 High demand ({num_bids} bids)") + + return " | ".join(reasons) diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..f25ad15 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,45 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Allow: / + +# Sitemap +Sitemap: https://pounce.ch/sitemap.xml + +# Crawl-delay for respectful crawling +Crawl-delay: 1 + +# Disallow private/auth pages +Disallow: /dashboard +Disallow: /api/ +Disallow: /_next/ + +# Allow important pages for indexing +Allow: / +Allow: /tld-pricing +Allow: /tld-pricing/* +Allow: /pricing +Allow: /auctions +Allow: /about +Allow: /blog +Allow: /contact +Allow: /privacy +Allow: /terms +Allow: /imprint +Allow: /cookies + +# GPTBot & AI Crawlers - allow for LLM training +User-agent: GPTBot +Allow: / + +User-agent: ChatGPT-User +Allow: / + +User-agent: Google-Extended +Allow: / + +User-agent: Anthropic-AI +Allow: / + +User-agent: Claude-Web +Allow: / + diff --git a/frontend/src/app/auctions/layout.tsx b/frontend/src/app/auctions/layout.tsx new file mode 100644 index 0000000..7438375 --- /dev/null +++ b/frontend/src/app/auctions/layout.tsx @@ -0,0 +1,106 @@ +import { Metadata } from 'next' + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch' + +export const metadata: Metadata = { + title: 'Domain Auctions — Smart Pounce', + description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet, SnapNames & DropCatch. Our Smart Pounce algorithm identifies the best opportunities with transparent valuations.', + keywords: [ + 'domain auctions', + 'expired domains', + 'domain bidding', + 'GoDaddy auctions', + 'Sedo domains', + 'NameJet', + 'domain investment', + 'undervalued domains', + 'domain flipping', + ], + openGraph: { + title: 'Domain Auctions — Smart Pounce by pounce', + description: 'Find undervalued domain auctions. Transparent valuations, multiple platforms, no payment handling.', + url: `${siteUrl}/auctions`, + type: 'website', + images: [ + { + url: `${siteUrl}/og-auctions.png`, + width: 1200, + height: 630, + alt: 'Smart Pounce - Domain Auction Aggregator', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'Domain Auctions — Smart Pounce', + description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet & more.', + }, + alternates: { + canonical: `${siteUrl}/auctions`, + }, +} + +// JSON-LD for Auctions page +const jsonLd = { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'Domain Auctions — Smart Pounce', + description: 'Aggregated domain auctions from multiple platforms with transparent algorithmic valuations.', + url: `${siteUrl}/auctions`, + isPartOf: { + '@type': 'WebSite', + name: 'pounce', + url: siteUrl, + }, + about: { + '@type': 'Service', + name: 'Smart Pounce', + description: 'Domain auction aggregation and opportunity analysis', + provider: { + '@type': 'Organization', + name: 'pounce', + }, + }, + mainEntity: { + '@type': 'ItemList', + name: 'Domain Auctions', + description: 'Live domain auctions from GoDaddy, Sedo, NameJet, SnapNames, and DropCatch', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'GoDaddy Auctions', + url: 'https://auctions.godaddy.com', + }, + { + '@type': 'ListItem', + position: 2, + name: 'Sedo', + url: 'https://sedo.com', + }, + { + '@type': 'ListItem', + position: 3, + name: 'NameJet', + url: 'https://namejet.com', + }, + ], + }, +} + +export default function AuctionsLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + + {children} + > + ) +} + diff --git a/frontend/src/app/auctions/page.tsx b/frontend/src/app/auctions/page.tsx index 484123a..be6111e 100644 --- a/frontend/src/app/auctions/page.tsx +++ b/frontend/src/app/auctions/page.tsx @@ -26,6 +26,14 @@ import { import Link from 'next/link' import clsx from 'clsx' +interface AuctionValuation { + estimated_value: number + value_ratio: number + potential_profit: number + confidence: string + valuation_formula: string +} + interface Auction { domain: string platform: string @@ -41,6 +49,7 @@ interface Auction { age_years: number | null tld: string affiliate_url: string + valuation: AuctionValuation | null } interface Opportunity { @@ -52,6 +61,7 @@ interface Opportunity { potential_profit: number opportunity_score: number recommendation: string + reasoning?: string } } @@ -369,7 +379,7 @@ export default function AuctionsPage() { -
Current Bid
@@ -377,6 +387,27 @@ export default function AuctionsPage() {
Est. Value
++ {formatCurrency(auction.valuation.estimated_value)} +
+Value Ratio
+= 1 ? "text-accent" : "text-foreground-muted" + )}> + {auction.valuation.value_ratio}× +
+Time Left
+
+ + + + {children} diff --git a/frontend/src/app/sitemap.ts b/frontend/src/app/sitemap.ts new file mode 100644 index 0000000..72b2988 --- /dev/null +++ b/frontend/src/app/sitemap.ts @@ -0,0 +1,101 @@ +import { MetadataRoute } from 'next' + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch' + +// Popular TLDs to include in sitemap +const popularTlds = [ + 'com', 'net', 'org', 'io', 'ai', 'co', 'dev', 'app', 'tech', 'xyz', + 'de', 'ch', 'uk', 'eu', 'fr', 'nl', 'at', 'it', 'es', 'pl', + 'info', 'biz', 'me', 'online', 'site', 'store', 'shop', 'blog', 'cloud', +] + +export default function sitemap(): MetadataRoute.Sitemap { + const now = new Date().toISOString() + + // Static pages + const staticPages: MetadataRoute.Sitemap = [ + { + url: siteUrl, + lastModified: now, + changeFrequency: 'daily', + priority: 1.0, + }, + { + url: `${siteUrl}/tld-pricing`, + lastModified: now, + changeFrequency: 'hourly', + priority: 0.9, + }, + { + url: `${siteUrl}/pricing`, + lastModified: now, + changeFrequency: 'weekly', + priority: 0.8, + }, + { + url: `${siteUrl}/auctions`, + lastModified: now, + changeFrequency: 'hourly', + priority: 0.8, + }, + { + url: `${siteUrl}/about`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.6, + }, + { + url: `${siteUrl}/blog`, + lastModified: now, + changeFrequency: 'weekly', + priority: 0.6, + }, + { + url: `${siteUrl}/contact`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.5, + }, + { + url: `${siteUrl}/careers`, + lastModified: now, + changeFrequency: 'monthly', + priority: 0.5, + }, + { + url: `${siteUrl}/privacy`, + lastModified: now, + changeFrequency: 'yearly', + priority: 0.3, + }, + { + url: `${siteUrl}/terms`, + lastModified: now, + changeFrequency: 'yearly', + priority: 0.3, + }, + { + url: `${siteUrl}/imprint`, + lastModified: now, + changeFrequency: 'yearly', + priority: 0.3, + }, + { + url: `${siteUrl}/cookies`, + lastModified: now, + changeFrequency: 'yearly', + priority: 0.3, + }, + ] + + // TLD detail pages (high value for SEO) + const tldPages: MetadataRoute.Sitemap = popularTlds.map((tld) => ({ + url: `${siteUrl}/tld-pricing/${tld}`, + lastModified: now, + changeFrequency: 'daily' as const, + priority: 0.7, + })) + + return [...staticPages, ...tldPages] +} + diff --git a/frontend/src/app/tld-pricing/layout.tsx b/frontend/src/app/tld-pricing/layout.tsx new file mode 100644 index 0000000..3986e4e --- /dev/null +++ b/frontend/src/app/tld-pricing/layout.tsx @@ -0,0 +1,86 @@ +import { Metadata } from 'next' + +const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch' + +export const metadata: Metadata = { + title: 'TLD Pricing — Compare 886+ Domain Extensions', + description: 'Compare domain registration prices across 886+ TLDs. Find the cheapest registrars for .com, .io, .ai, .dev and more. Updated daily with real pricing data.', + keywords: [ + 'TLD pricing', + 'domain prices', + 'registrar comparison', + 'domain extension prices', + '.com price', + '.io price', + '.ai price', + '.dev price', + 'cheapest domain registrar', + 'domain cost comparison', + ], + openGraph: { + title: 'TLD Pricing — Compare 886+ Domain Extensions', + description: 'Find the cheapest registrar for any domain extension. Compare prices across 886+ TLDs.', + url: `${siteUrl}/tld-pricing`, + type: 'website', + images: [ + { + url: `${siteUrl}/og-tld-pricing.png`, + width: 1200, + height: 630, + alt: 'TLD Pricing Comparison - pounce', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'TLD Pricing — Compare 886+ TLDs', + description: 'Find the cheapest registrar for any domain extension.', + }, + alternates: { + canonical: `${siteUrl}/tld-pricing`, + }, +} + +// JSON-LD for TLD Pricing +const jsonLd = { + '@context': 'https://schema.org', + '@type': 'WebPage', + name: 'TLD Pricing Comparison', + description: 'Compare domain registration prices across 886+ top-level domains', + url: `${siteUrl}/tld-pricing`, + isPartOf: { + '@type': 'WebSite', + name: 'pounce', + url: siteUrl, + }, + mainEntity: { + '@type': 'ItemList', + name: 'Top-Level Domains', + description: 'Comprehensive list of TLD pricing from major registrars', + numberOfItems: 886, + itemListElement: [ + { '@type': 'ListItem', position: 1, name: '.com', description: 'Most popular generic TLD' }, + { '@type': 'ListItem', position: 2, name: '.net', description: 'Network-focused TLD' }, + { '@type': 'ListItem', position: 3, name: '.org', description: 'Organization TLD' }, + { '@type': 'ListItem', position: 4, name: '.io', description: 'Tech startup favorite' }, + { '@type': 'ListItem', position: 5, name: '.ai', description: 'AI and tech industry TLD' }, + ], + }, +} + +export default function TldPricingLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + + {children} + > + ) +} + diff --git a/frontend/src/lib/seo.ts b/frontend/src/lib/seo.ts new file mode 100644 index 0000000..1c3810e --- /dev/null +++ b/frontend/src/lib/seo.ts @@ -0,0 +1,259 @@ +/** + * SEO Configuration for pounce.ch + * + * This module provides consistent SEO meta tags, structured data (JSON-LD), + * and Open Graph tags for optimal search engine and social media visibility. + */ + +export const siteConfig = { + name: 'pounce', + domain: 'pounce.ch', + url: 'https://pounce.ch', + description: 'Professional domain intelligence platform. Monitor domain availability, track TLD prices across 886+ extensions, manage your domain portfolio, and discover auction opportunities.', + tagline: 'The domains you want. The moment they\'re free.', + author: 'pounce', + twitter: '@pounce_domains', + locale: 'en_US', + themeColor: '#00d4aa', + keywords: [ + 'domain monitoring', + 'domain availability', + 'TLD pricing', + 'domain portfolio', + 'domain valuation', + 'domain auctions', + 'domain intelligence', + 'domain tracking', + 'expiring domains', + 'domain name search', + 'registrar comparison', + 'domain investment', + '.com domains', + '.ai domains', + '.io domains', + ], +} + +export interface PageSEO { + title: string + description: string + keywords?: string[] + canonical?: string + ogImage?: string + ogType?: 'website' | 'article' | 'product' + noindex?: boolean +} + +/** + * Generate full page title with site name + */ +export function getPageTitle(pageTitle?: string): string { + if (!pageTitle) return `${siteConfig.name} — Domain Intelligence Platform` + return `${pageTitle} | ${siteConfig.name}` +} + +/** + * Generate JSON-LD structured data for a page + */ +export function generateStructuredData(type: string, data: Record