diff --git a/YIELD_SETUP.md b/YIELD_SETUP.md new file mode 100644 index 0000000..fd6f455 --- /dev/null +++ b/YIELD_SETUP.md @@ -0,0 +1,256 @@ +# Pounce Yield - Complete Setup Guide + +This guide covers the complete setup of the Yield/Intent Routing feature. + +## Overview + +Pounce Yield allows users to monetize their parked domains by: +1. Detecting user intent from domain names (e.g., "zahnarzt-zuerich.ch" → Medical/Dental) +2. Routing visitors to relevant affiliate partners +3. Tracking clicks, leads, and sales +4. Splitting revenue 70/30 (user/Pounce) + +## Architecture + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ User Domain │────▶│ Pounce Yield │────▶│ Affiliate │ +│ (DNS → Pounce) │ │ Routing Engine │ │ Partner │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + │ + ▼ + ┌──────────────────┐ + │ Transaction │ + │ Tracking │ + └──────────────────┘ +``` + +## Setup Steps + +### 1. Database Setup + +The yield tables are created automatically on startup. To apply migrations to an existing database: + +```bash +cd backend +python -c "from app.database import init_db; import asyncio; asyncio.run(init_db())" +``` + +### 2. Seed Affiliate Partners + +Populate the affiliate partners with default Swiss/German partners: + +```bash +cd backend +python scripts/seed_yield_partners.py +``` + +This seeds ~30 partners across categories: +- Medical (Dental, General, Beauty) +- Finance (Insurance, Mortgage, Banking) +- Legal +- Real Estate +- Travel +- Automotive +- Jobs +- Education +- Technology/Hosting +- Shopping +- Food/Delivery + +### 3. Configure DNS + +For yield domains to work, you need to set up DNS infrastructure: + +#### Option A: Dedicated Nameservers (Recommended for Scale) + +1. Set up two nameserver instances (e.g., `ns1.pounce.io`, `ns2.pounce.io`) +2. Run PowerDNS or similar with a backend that queries your yield_domains table +3. Return A records pointing to your yield routing service + +#### Option B: CNAME Approach (Simpler) + +1. Set up a wildcard SSL certificate for `*.yield.pounce.io` +2. Configure Nginx/Caddy to handle all incoming hosts +3. Users add CNAME: `@ → yield.pounce.io` + +### 4. Nginx Configuration + +For host-based routing, add this to your nginx config: + +```nginx +# Yield domain catch-all +server { + listen 443 ssl http2; + server_name ~^(?.+)$; + + # Wildcard cert + ssl_certificate /etc/ssl/yield.pounce.io.crt; + ssl_certificate_key /etc/ssl/yield.pounce.io.key; + + location / { + proxy_pass http://backend:8000/api/v1/r/$domain; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } +} +``` + +### 5. Partner Integration + +Each affiliate partner requires: + +1. **Tracking URL Template**: How to pass click IDs to the partner +2. **Webhook URL**: Where the partner sends conversion data back + +Update partners in the database or via admin panel: + +```sql +UPDATE affiliate_partners +SET tracking_url_template = 'https://partner.com/?clickid={click_id}&ref={domain}' +WHERE slug = 'partner_slug'; +``` + +### 6. Webhook Configuration + +Partners send conversion data to: + +``` +POST https://api.pounce.ch/api/v1/yield-webhooks/{partner_slug} + +{ + "event_type": "lead", + "domain": "zahnarzt-zuerich.ch", + "transaction_id": "abc123", + "amount": 25.00, + "currency": "CHF" +} +``` + +For Awin network, use the dedicated endpoint: +``` +POST https://api.pounce.ch/api/v1/yield-webhooks/awin/postback +``` + +## API Endpoints + +### Public + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/yield/analyze?domain=X` | Analyze domain intent (no auth) | +| GET | `/api/v1/yield/partners` | List available partners | + +### Authenticated (User) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/yield/dashboard` | User yield dashboard | +| GET | `/api/v1/yield/domains` | List user's yield domains | +| POST | `/api/v1/yield/activate` | Activate a domain | +| POST | `/api/v1/yield/domains/{id}/verify` | Verify DNS setup | +| GET | `/api/v1/yield/transactions` | Transaction history | +| GET | `/api/v1/yield/payouts` | Payout history | + +### Routing + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/r/{domain}` | Route traffic & track click | +| GET | `/api/v1/r/{domain}?direct=true` | Direct redirect (no landing) | + +### Webhooks (Partner → Pounce) + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/yield-webhooks/{partner}` | Generic partner webhook | +| POST | `/api/v1/yield-webhooks/awin/postback` | Awin network postback | +| POST | `/api/v1/yield-webhooks/confirm/{tx_id}` | Manual confirmation (internal) | +| POST | `/api/v1/yield-webhooks/batch-import` | Bulk import (internal) | + +## Revenue Model + +- **Clicks**: Usually CPC (cost per click), CHF 0.10-0.60 +- **Leads**: CPL (cost per lead), CHF 15-120 +- **Sales**: CPS (cost per sale), 2-10% of sale value + +Revenue split: +- **User**: 70% +- **Pounce**: 30% + +## Intent Categories + +The IntentDetector recognizes these categories: + +| Category | Subcategories | Example Domains | +|----------|---------------|-----------------| +| medical | dental, general, beauty | zahnarzt.ch, arzt-bern.ch | +| finance | insurance, mortgage, banking | versicherung.ch, hypothek.ch | +| legal | general | anwalt-zuerich.ch | +| realestate | buy, rent | wohnung-mieten.ch | +| travel | flights, hotels | flug-buchen.ch | +| auto | buy, service | autokauf.ch | +| jobs | - | stellenmarkt.ch | +| education | - | kurse-online.ch | +| tech | hosting, software | webhosting.ch | +| shopping | general, fashion | mode-shop.ch | +| food | restaurant, delivery | pizza-lieferung.ch | + +## Monitoring + +### Metrics + +Enable Prometheus metrics: + +```env +ENABLE_METRICS=true +``` + +Key yield metrics: +- `yield_clicks_total{domain, partner}` +- `yield_conversions_total{domain, partner, type}` +- `yield_revenue_total{currency}` + +### Alerts + +Set up alerts for: +- Webhook failures +- Low conversion rates +- DNS verification failures +- Partner API errors + +## Troubleshooting + +### Domain not routing + +1. Check DNS: `dig +short {domain}` +2. Verify domain status: `SELECT status FROM yield_domains WHERE domain = '{domain}'` +3. Check nginx logs for routing errors + +### No conversions + +1. Verify partner webhook URL is correct +2. Check webhook logs for incoming calls +3. Validate transaction ID format + +### Low revenue + +1. Check intent detection: Some domains may be classified as "generic" +2. Review partner matching: Higher-priority partners should be assigned +3. Analyze geo distribution: Swiss visitors convert better + +## Security Considerations + +- All partner webhooks should use HMAC signature verification +- IP addresses are hashed before storage (privacy) +- User revenue data is isolated by user_id +- Rate limiting on routing endpoint + +## Support + +For issues with: +- Partner integrations: partners@pounce.ch +- Technical issues: dev@pounce.ch +- Payout questions: finance@pounce.ch + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 149e038..53b5529 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -19,6 +19,8 @@ from app.api.sniper_alerts import router as sniper_alerts_router from app.api.seo import router as seo_router from app.api.dashboard import router as dashboard_router from app.api.yield_domains import router as yield_router +from app.api.yield_webhooks import router as yield_webhooks_router +from app.api.yield_routing import router as yield_routing_router api_router = APIRouter() @@ -45,6 +47,8 @@ api_router.include_router(seo_router, prefix="/seo", tags=["SEO Data - Tycoon"]) # Yield / Intent Routing - Passive income from parked domains api_router.include_router(yield_router, tags=["Yield - Intent Routing"]) +api_router.include_router(yield_webhooks_router, tags=["Yield - Webhooks"]) +api_router.include_router(yield_routing_router, tags=["Yield - Routing"]) # Support & Communication api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"]) diff --git a/backend/app/api/yield_routing.py b/backend/app/api/yield_routing.py new file mode 100644 index 0000000..8c0b510 --- /dev/null +++ b/backend/app/api/yield_routing.py @@ -0,0 +1,381 @@ +""" +Yield Domain Routing API. + +This handles incoming HTTP requests to yield domains: +1. Detect the domain from the Host header +2. Look up the yield configuration +3. Track the click +4. Redirect to the appropriate affiliate landing page + +In production, this runs on a separate subdomain or IP (yield.pounce.io) +that yield domains CNAME to. +""" + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Request, Response, HTTPException +from fastapi.responses import RedirectResponse, HTMLResponse +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.config import get_settings +from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner +from app.services.intent_detector import detect_domain_intent + +logger = logging.getLogger(__name__) +settings = get_settings() + +router = APIRouter(prefix="/r", tags=["yield-routing"]) + +# Revenue split +USER_REVENUE_SHARE = 0.70 + + +def hash_ip(ip: str) -> str: + """Hash IP for privacy-compliant storage.""" + import hashlib + return hashlib.sha256(ip.encode()).hexdigest()[:32] + + +def generate_tracking_url( + partner: AffiliatePartner, + yield_domain: YieldDomain, + click_id: int, +) -> str: + """ + Generate the tracking URL for a partner. + + Most affiliate networks expect parameters like: + - clickid / subid: Our click tracking ID + - ref: Domain name or user reference + """ + # If partner has a tracking URL template, use it + if partner.tracking_url_template: + return partner.tracking_url_template.format( + click_id=click_id, + domain=yield_domain.domain, + domain_id=yield_domain.id, + partner=partner.slug, + ) + + # Default fallbacks by network + network_urls = { + "comparis_dental": f"https://www.comparis.ch/zahnarzt?subid={click_id}&ref={yield_domain.domain}", + "comparis_health": f"https://www.comparis.ch/krankenkassen?subid={click_id}&ref={yield_domain.domain}", + "comparis_insurance": f"https://www.comparis.ch/versicherungen?subid={click_id}&ref={yield_domain.domain}", + "comparis_hypo": f"https://www.comparis.ch/hypotheken?subid={click_id}&ref={yield_domain.domain}", + "comparis_auto": f"https://www.comparis.ch/autoversicherung?subid={click_id}&ref={yield_domain.domain}", + "comparis_immo": f"https://www.comparis.ch/immobilien?subid={click_id}&ref={yield_domain.domain}", + "homegate": f"https://www.homegate.ch/?ref=pounce&clickid={click_id}", + "immoscout": f"https://www.immoscout24.ch/?ref=pounce&clickid={click_id}", + "autoscout": f"https://www.autoscout24.ch/?ref=pounce&clickid={click_id}", + "jobs_ch": f"https://www.jobs.ch/?ref=pounce&clickid={click_id}", + "booking_com": f"https://www.booking.com/?aid=pounce&clickid={click_id}", + "hostpoint": f"https://www.hostpoint.ch/?ref=pounce&clickid={click_id}", + "infomaniak": f"https://www.infomaniak.com/?ref=pounce&clickid={click_id}", + "galaxus": f"https://www.galaxus.ch/?ref=pounce&clickid={click_id}", + "zalando": f"https://www.zalando.ch/?ref=pounce&clickid={click_id}", + } + + if partner.slug in network_urls: + return network_urls[partner.slug] + + # Generic fallback - show Pounce marketplace + return f"{settings.site_url}/buy?ref={yield_domain.domain}&clickid={click_id}" + + +def generate_landing_page( + yield_domain: YieldDomain, + partner: Optional[AffiliatePartner], + click_id: int, +) -> str: + """ + Generate an interstitial landing page. + + Shows for a moment before redirecting, to: + 1. Improve user experience + 2. Allow for A/B testing + 3. Comply with affiliate disclosure requirements + """ + intent = detect_domain_intent(yield_domain.domain) + + # Partner info + partner_name = partner.name if partner else "Partner" + partner_desc = partner.description if partner else "Find the best offers" + + # Generate redirect URL + redirect_url = ( + generate_tracking_url(partner, yield_domain, click_id) + if partner else f"{settings.site_url}/buy" + ) + + return f""" + + + + + + + {yield_domain.domain} - Redirecting + + + +
+
{yield_domain.domain}
+
{intent.category.replace('_', ' ')}
+ +
+ +
Redirecting to {partner_name}...
+
{partner_desc}
+ +

+ Click here if not redirected +

+
+ + + + + + +""" + + +@router.get("/{domain}") +async def route_yield_domain( + domain: str, + request: Request, + db: Session = Depends(get_db), + direct: bool = False, # Skip landing page if true +): + """ + Route traffic for a yield domain. + + This is the main entry point for yield domain traffic. + + Query params: + - direct: If true, redirect immediately without landing page + """ + domain = domain.lower().strip() + + # Find yield domain + yield_domain = db.query(YieldDomain).filter( + YieldDomain.domain == domain, + YieldDomain.status == "active", + ).first() + + if not yield_domain: + # Domain not found or not active - show error page + logger.warning(f"Route request for unknown/inactive domain: {domain}") + return HTMLResponse( + content=f""" + + Domain Not Active + +

Domain Not Active

+

The domain {domain} is not currently active for yield routing.

+

Visit Pounce

+ + + """, + status_code=404 + ) + + # Get partner + partner = None + if yield_domain.partner_id: + partner = db.query(AffiliatePartner).filter( + AffiliatePartner.id == yield_domain.partner_id, + AffiliatePartner.is_active == True, + ).first() + + # If no partner assigned, try to find one based on intent + if not partner and yield_domain.detected_intent: + partner = db.query(AffiliatePartner).filter( + AffiliatePartner.intent_categories.contains(yield_domain.detected_intent.split('_')[0]), + AffiliatePartner.is_active == True, + ).order_by(AffiliatePartner.priority.desc()).first() + + # Create click transaction + client_ip = request.client.host if request.client else None + user_agent = request.headers.get("user-agent") + referrer = request.headers.get("referer") + + transaction = YieldTransaction( + yield_domain_id=yield_domain.id, + event_type="click", + partner_slug=partner.slug if partner else "unknown", + gross_amount=0, + net_amount=0, + currency="CHF", + referrer=referrer, + user_agent=user_agent[:500] if user_agent else None, + ip_hash=hash_ip(client_ip) if client_ip else None, + status="confirmed", + confirmed_at=datetime.utcnow(), + ) + + db.add(transaction) + + # Update domain stats + yield_domain.total_clicks += 1 + yield_domain.last_click_at = datetime.utcnow() + + db.commit() + db.refresh(transaction) + + # Generate redirect URL + redirect_url = ( + generate_tracking_url(partner, yield_domain, transaction.id) + if partner else f"{settings.site_url}/buy?ref={domain}" + ) + + # Direct redirect or show landing page + if direct: + return RedirectResponse(url=redirect_url, status_code=302) + + # Show interstitial landing page + html = generate_landing_page(yield_domain, partner, transaction.id) + return HTMLResponse(content=html) + + +@router.get("/") +async def yield_routing_info(): + """Info endpoint for yield routing service.""" + return { + "service": "Pounce Yield Routing", + "version": "1.0.0", + "docs": f"{settings.site_url}/docs#/yield-routing", + "status": "active", + } + + +# ============================================================================ +# Host-based routing (for production deployment) +# ============================================================================ + +@router.api_route("/catch-all", methods=["GET", "HEAD"]) +async def catch_all_route( + request: Request, + db: Session = Depends(get_db), +): + """ + Catch-all route for host-based routing. + + In production, this endpoint handles requests where the Host header + is the yield domain itself (e.g., zahnarzt-zuerich.ch). + + This requires: + 1. Yield domains to CNAME to yield.pounce.io + 2. Nginx/Caddy to route all hosts to this backend + 3. This endpoint to parse the Host header + """ + host = request.headers.get("host", "").lower() + + # Remove port if present + if ":" in host: + host = host.split(":")[0] + + # Skip our own domains + our_domains = ["pounce.ch", "pounce.io", "localhost", "127.0.0.1"] + if any(host.endswith(d) for d in our_domains): + return {"status": "not a yield domain", "host": host} + + # Look up yield domain + yield_domain = db.query(YieldDomain).filter( + YieldDomain.domain == host, + YieldDomain.status == "active", + ).first() + + if not yield_domain: + return HTMLResponse( + content=f"

Domain {host} not configured

", + status_code=404 + ) + + # Redirect to routing endpoint + return RedirectResponse( + url=f"/api/v1/r/{host}", + status_code=302 + ) + diff --git a/backend/app/api/yield_webhooks.py b/backend/app/api/yield_webhooks.py new file mode 100644 index 0000000..185c2e6 --- /dev/null +++ b/backend/app/api/yield_webhooks.py @@ -0,0 +1,457 @@ +""" +Webhook endpoints for Yield affiliate partner callbacks. + +Partners call these endpoints to report: +- Clicks (redirect happened) +- Leads (form submitted, signup, etc.) +- Sales (purchase completed) + +Each partner may have different authentication methods: +- HMAC signature verification +- API key in header +- IP whitelist +""" + +import hashlib +import hmac +import json +import logging +from datetime import datetime +from decimal import Decimal +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, Header, BackgroundTasks +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import get_db +from app.config import get_settings +from app.models.yield_domain import YieldDomain, YieldTransaction, AffiliatePartner + +logger = logging.getLogger(__name__) +settings = get_settings() + +router = APIRouter(prefix="/yield-webhooks", tags=["yield-webhooks"]) + +# Revenue split: User gets 70%, Pounce keeps 30% +USER_REVENUE_SHARE = Decimal("0.70") + + +# ============================================================================ +# Schemas +# ============================================================================ + +class PartnerEvent(BaseModel): + """Generic partner event payload.""" + event_type: str = Field(..., description="click, lead, or sale") + domain: str = Field(..., description="The yield domain that generated this event") + transaction_id: Optional[str] = Field(None, description="Partner's transaction ID") + amount: Optional[float] = Field(None, description="Gross commission amount") + currency: Optional[str] = Field("CHF", description="Currency code") + + # Optional attribution data + geo_country: Optional[str] = None + referrer: Optional[str] = None + user_agent: Optional[str] = None + + # Optional metadata + metadata: Optional[dict] = None + + +class WebhookResponse(BaseModel): + """Response for webhook calls.""" + success: bool + transaction_id: Optional[int] = None + message: str + + +# ============================================================================ +# Signature Verification Helpers +# ============================================================================ + +def verify_hmac_signature( + payload: bytes, + signature: str, + secret: str, + algorithm: str = "sha256" +) -> bool: + """Verify HMAC signature for webhook payload.""" + expected = hmac.new( + secret.encode(), + payload, + hashlib.sha256 if algorithm == "sha256" else hashlib.sha1 + ).hexdigest() + + return hmac.compare_digest(signature, expected) + + +def hash_ip(ip: str) -> str: + """Hash IP address for privacy-compliant storage.""" + return hashlib.sha256(ip.encode()).hexdigest()[:32] + + +# ============================================================================ +# Generic Webhook Endpoint +# ============================================================================ + +@router.post("/{partner_slug}", response_model=WebhookResponse) +async def receive_partner_webhook( + partner_slug: str, + event: PartnerEvent, + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + x_webhook_signature: Optional[str] = Header(None), + x_api_key: Optional[str] = Header(None), +): + """ + Receive webhook callback from affiliate partner. + + Partners POST events here when clicks, leads, or sales occur. + """ + # 1. Find partner + partner = db.query(AffiliatePartner).filter( + AffiliatePartner.slug == partner_slug, + AffiliatePartner.is_active == True, + ).first() + + if not partner: + logger.warning(f"Webhook from unknown partner: {partner_slug}") + raise HTTPException(status_code=404, detail="Unknown partner") + + # 2. Verify authentication (if configured) + # Note: In production, store partner API keys in a secure location + # For now, we accept webhooks if the partner exists + # TODO: Add proper signature verification per partner + + # 3. Find yield domain + yield_domain = db.query(YieldDomain).filter( + YieldDomain.domain == event.domain.lower(), + YieldDomain.status == "active", + ).first() + + if not yield_domain: + logger.warning(f"Webhook for unknown/inactive domain: {event.domain}") + raise HTTPException(status_code=404, detail="Domain not found or inactive") + + # 4. Calculate amounts + gross_amount = Decimal(str(event.amount)) if event.amount else Decimal("0") + net_amount = gross_amount * USER_REVENUE_SHARE + + # 5. Get client IP for hashing + client_ip = request.client.host if request.client else None + ip_hash = hash_ip(client_ip) if client_ip else None + + # 6. Create transaction + transaction = YieldTransaction( + yield_domain_id=yield_domain.id, + event_type=event.event_type, + partner_slug=partner_slug, + partner_transaction_id=event.transaction_id, + gross_amount=gross_amount, + net_amount=net_amount, + currency=event.currency or "CHF", + referrer=event.referrer, + user_agent=event.user_agent, + geo_country=event.geo_country, + ip_hash=ip_hash, + status="pending" if event.event_type in ["lead", "sale"] else "confirmed", + confirmed_at=datetime.utcnow() if event.event_type == "click" else None, + ) + + db.add(transaction) + + # 7. Update domain aggregates + if event.event_type == "click": + yield_domain.total_clicks += 1 + yield_domain.last_click_at = datetime.utcnow() + elif event.event_type in ["lead", "sale"]: + yield_domain.total_conversions += 1 + yield_domain.last_conversion_at = datetime.utcnow() + # Add revenue when confirmed + if transaction.status == "confirmed": + yield_domain.total_revenue += net_amount + + db.commit() + db.refresh(transaction) + + logger.info( + f"Webhook processed: {partner_slug} -> {event.domain} " + f"({event.event_type}, gross={gross_amount}, net={net_amount})" + ) + + return WebhookResponse( + success=True, + transaction_id=transaction.id, + message=f"Event {event.event_type} recorded successfully" + ) + + +# ============================================================================ +# Awin-Specific Webhook +# ============================================================================ + +class AwinEvent(BaseModel): + """Awin network postback format.""" + clickRef: str # Our yield domain ID or domain name + transactionId: str + commission: float + commissionCurrency: str = "CHF" + status: str # "pending", "approved", "declined" + transactionType: str # "sale", "lead" + + +@router.post("/awin/postback", response_model=WebhookResponse) +async def receive_awin_postback( + event: AwinEvent, + request: Request, + db: Session = Depends(get_db), + x_awin_signature: Optional[str] = Header(None), +): + """ + Receive postback from Awin affiliate network. + + Awin sends postbacks for tracked conversions. + """ + # Find domain by click reference + yield_domain = db.query(YieldDomain).filter( + YieldDomain.domain == event.clickRef.lower(), + ).first() + + if not yield_domain: + # Try to find by ID if clickRef is numeric + try: + domain_id = int(event.clickRef) + yield_domain = db.query(YieldDomain).filter( + YieldDomain.id == domain_id, + ).first() + except ValueError: + pass + + if not yield_domain: + logger.warning(f"Awin postback for unknown domain: {event.clickRef}") + raise HTTPException(status_code=404, detail="Domain not found") + + # Calculate amounts + gross_amount = Decimal(str(event.commission)) + net_amount = gross_amount * USER_REVENUE_SHARE + + # Map Awin status to our status + status_map = { + "pending": "pending", + "approved": "confirmed", + "declined": "rejected", + } + status = status_map.get(event.status.lower(), "pending") + + # Create or update transaction + existing_tx = db.query(YieldTransaction).filter( + YieldTransaction.partner_transaction_id == event.transactionId, + YieldTransaction.partner_slug.like("awin%"), + ).first() + + if existing_tx: + # Update existing transaction + existing_tx.status = status + if status == "confirmed": + existing_tx.confirmed_at = datetime.utcnow() + yield_domain.total_revenue += net_amount + transaction_id = existing_tx.id + else: + # Create new transaction + transaction = YieldTransaction( + yield_domain_id=yield_domain.id, + event_type="lead" if event.transactionType.lower() == "lead" else "sale", + partner_slug=f"awin_{yield_domain.active_route or 'unknown'}", + partner_transaction_id=event.transactionId, + gross_amount=gross_amount, + net_amount=net_amount, + currency=event.commissionCurrency, + status=status, + confirmed_at=datetime.utcnow() if status == "confirmed" else None, + ) + db.add(transaction) + + # Update domain stats + yield_domain.total_conversions += 1 + yield_domain.last_conversion_at = datetime.utcnow() + if status == "confirmed": + yield_domain.total_revenue += net_amount + + db.flush() + transaction_id = transaction.id + + db.commit() + + logger.info(f"Awin postback processed: {event.transactionId} -> {status}") + + return WebhookResponse( + success=True, + transaction_id=transaction_id, + message=f"Awin event processed ({status})" + ) + + +# ============================================================================ +# Transaction Confirmation Endpoint (Admin/Internal) +# ============================================================================ + +@router.post("/confirm/{transaction_id}", response_model=WebhookResponse) +async def confirm_transaction( + transaction_id: int, + db: Session = Depends(get_db), + x_internal_key: Optional[str] = Header(None), +): + """ + Manually confirm a pending transaction. + + Internal endpoint for admin use or automated confirmation. + """ + # Basic auth check - in production, use proper admin auth + internal_key = getattr(settings, 'internal_api_key', None) or settings.secret_key + if x_internal_key != internal_key: + raise HTTPException(status_code=401, detail="Unauthorized") + + transaction = db.query(YieldTransaction).filter( + YieldTransaction.id == transaction_id, + YieldTransaction.status == "pending", + ).first() + + if not transaction: + raise HTTPException(status_code=404, detail="Transaction not found or not pending") + + # Confirm transaction + transaction.status = "confirmed" + transaction.confirmed_at = datetime.utcnow() + + # Update domain revenue + yield_domain = db.query(YieldDomain).filter( + YieldDomain.id == transaction.yield_domain_id + ).first() + + if yield_domain: + yield_domain.total_revenue += transaction.net_amount + + db.commit() + + logger.info(f"Transaction {transaction_id} confirmed manually") + + return WebhookResponse( + success=True, + transaction_id=transaction_id, + message="Transaction confirmed" + ) + + +# ============================================================================ +# Batch Transaction Import (for reconciliation) +# ============================================================================ + +class BatchTransactionItem(BaseModel): + """Single transaction in batch import.""" + domain: str + event_type: str + partner_slug: str + transaction_id: str + gross_amount: float + currency: str = "CHF" + status: str = "confirmed" + created_at: Optional[str] = None + + +class BatchImportRequest(BaseModel): + """Batch transaction import request.""" + transactions: list[BatchTransactionItem] + + +class BatchImportResponse(BaseModel): + """Batch import response.""" + success: bool + imported: int + skipped: int + errors: list[str] + + +@router.post("/batch-import", response_model=BatchImportResponse) +async def batch_import_transactions( + request_data: BatchImportRequest, + db: Session = Depends(get_db), + x_internal_key: Optional[str] = Header(None), +): + """ + Batch import transactions for reconciliation. + + Internal endpoint for importing partner reports. + """ + internal_key = getattr(settings, 'internal_api_key', None) or settings.secret_key + if x_internal_key != internal_key: + raise HTTPException(status_code=401, detail="Unauthorized") + + imported = 0 + skipped = 0 + errors = [] + + for item in request_data.transactions: + try: + # Find domain + yield_domain = db.query(YieldDomain).filter( + YieldDomain.domain == item.domain.lower(), + ).first() + + if not yield_domain: + errors.append(f"Domain not found: {item.domain}") + skipped += 1 + continue + + # Check for duplicate + existing = db.query(YieldTransaction).filter( + YieldTransaction.partner_transaction_id == item.transaction_id, + YieldTransaction.partner_slug == item.partner_slug, + ).first() + + if existing: + skipped += 1 + continue + + # Create transaction + gross = Decimal(str(item.gross_amount)) + net = gross * USER_REVENUE_SHARE + + tx = YieldTransaction( + yield_domain_id=yield_domain.id, + event_type=item.event_type, + partner_slug=item.partner_slug, + partner_transaction_id=item.transaction_id, + gross_amount=gross, + net_amount=net, + currency=item.currency, + status=item.status, + confirmed_at=datetime.utcnow() if item.status == "confirmed" else None, + ) + db.add(tx) + + # Update domain stats + if item.event_type == "click": + yield_domain.total_clicks += 1 + else: + yield_domain.total_conversions += 1 + + if item.status == "confirmed": + yield_domain.total_revenue += net + + imported += 1 + + except Exception as e: + errors.append(f"Error importing {item.domain}/{item.transaction_id}: {str(e)}") + skipped += 1 + + db.commit() + + return BatchImportResponse( + success=len(errors) == 0, + imported=imported, + skipped=skipped, + errors=errors[:10] # Limit error messages + ) + diff --git a/backend/app/db_migrations.py b/backend/app/db_migrations.py index 15972ce..f516eff 100644 --- a/backend/app/db_migrations.py +++ b/backend/app/db_migrations.py @@ -127,6 +127,45 @@ async def apply_migrations(conn: AsyncConnection) -> None: ) ) + # ---------------------------------------------------- + # 5) Yield tables indexes + # ---------------------------------------------------- + if await _table_exists(conn, "yield_domains"): + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_yield_domains_user_status " + "ON yield_domains(user_id, status)" + ) + ) + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_yield_domains_domain " + "ON yield_domains(domain)" + ) + ) + + if await _table_exists(conn, "yield_transactions"): + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_yield_tx_domain_created " + "ON yield_transactions(yield_domain_id, created_at)" + ) + ) + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_yield_tx_status_created " + "ON yield_transactions(status, created_at)" + ) + ) + + if await _table_exists(conn, "yield_payouts"): + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS ix_yield_payouts_user_status " + "ON yield_payouts(user_id, status)" + ) + ) + logger.info("DB migrations: done") diff --git a/backend/app/seeds/__init__.py b/backend/app/seeds/__init__.py new file mode 100644 index 0000000..34aba60 --- /dev/null +++ b/backend/app/seeds/__init__.py @@ -0,0 +1,6 @@ +"""Database seed scripts.""" + +from app.seeds.yield_partners import seed_partners + +__all__ = ["seed_partners"] + diff --git a/backend/app/seeds/yield_partners.py b/backend/app/seeds/yield_partners.py new file mode 100644 index 0000000..57f23a6 --- /dev/null +++ b/backend/app/seeds/yield_partners.py @@ -0,0 +1,458 @@ +""" +Seed data for Yield affiliate partners. + +Run via: python -m app.seeds.yield_partners +Or: from app.seeds.yield_partners import seed_partners; await seed_partners(db) +""" + +import asyncio +import logging +from decimal import Decimal +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import AsyncSessionLocal +from app.models.yield_domain import AffiliatePartner + +logger = logging.getLogger(__name__) + +# Partner configurations grouped by category +PARTNER_SEED_DATA: list[dict[str, Any]] = [ + # ========================================================================= + # MEDICAL / HEALTH + # ========================================================================= + { + "name": "Comparis Dental", + "slug": "comparis_dental", + "network": "direct", + "intent_categories": "medical_dental", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("25.00"), + "payout_currency": "CHF", + "description": "Dental treatment comparison platform. High conversion for Swiss dental searches.", + "priority": 100, + }, + { + "name": "Swisssmile", + "slug": "swisssmile", + "network": "awin", + "intent_categories": "medical_dental", + "geo_countries": "CH,DE,AT", + "payout_type": "cpl", + "payout_amount": Decimal("30.00"), + "payout_currency": "CHF", + "description": "Premium dental clinics network.", + "priority": 90, + }, + { + "name": "Comparis Health", + "slug": "comparis_health", + "network": "direct", + "intent_categories": "medical_general", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("20.00"), + "payout_currency": "CHF", + "description": "Health insurance comparison.", + "priority": 100, + }, + { + "name": "Sanitas", + "slug": "sanitas", + "network": "awin", + "intent_categories": "medical_general", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("35.00"), + "payout_currency": "CHF", + "description": "Swiss health insurance provider.", + "priority": 80, + }, + { + "name": "Swiss Esthetic", + "slug": "swissesthetic", + "network": "direct", + "intent_categories": "medical_beauty", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("40.00"), + "payout_currency": "CHF", + "description": "Aesthetic treatments and beauty clinics.", + "priority": 90, + }, + + # ========================================================================= + # FINANCE / INSURANCE + # ========================================================================= + { + "name": "Comparis Insurance", + "slug": "comparis_insurance", + "network": "direct", + "intent_categories": "finance_insurance", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("30.00"), + "payout_currency": "CHF", + "description": "All-in-one insurance comparison.", + "priority": 100, + }, + { + "name": "Bonus.ch", + "slug": "bonus_ch", + "network": "awin", + "intent_categories": "finance_insurance", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("25.00"), + "payout_currency": "CHF", + "description": "Swiss insurance comparison portal.", + "priority": 80, + }, + { + "name": "Comparis Hypo", + "slug": "comparis_hypo", + "network": "direct", + "intent_categories": "finance_mortgage", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("100.00"), + "payout_currency": "CHF", + "description": "Mortgage comparison - high value leads.", + "priority": 100, + }, + { + "name": "MoneyPark", + "slug": "moneypark", + "network": "awin", + "intent_categories": "finance_mortgage", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("120.00"), + "payout_currency": "CHF", + "description": "Independent mortgage broker.", + "priority": 90, + }, + { + "name": "Neon Bank", + "slug": "neon_bank", + "network": "partnerstack", + "intent_categories": "finance_banking", + "geo_countries": "CH", + "payout_type": "cps", + "payout_amount": Decimal("50.00"), + "payout_currency": "CHF", + "description": "Swiss mobile banking app.", + "priority": 80, + }, + + # ========================================================================= + # LEGAL + # ========================================================================= + { + "name": "Legal CH", + "slug": "legal_ch", + "network": "direct", + "intent_categories": "legal_general", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("50.00"), + "payout_currency": "CHF", + "description": "Lawyer matching service.", + "priority": 100, + }, + { + "name": "Anwalt24", + "slug": "anwalt24", + "network": "awin", + "intent_categories": "legal_general", + "geo_countries": "DE,AT", + "payout_type": "cpl", + "payout_amount": Decimal("35.00"), + "payout_currency": "EUR", + "description": "German lawyer directory.", + "priority": 80, + }, + + # ========================================================================= + # REAL ESTATE + # ========================================================================= + { + "name": "Homegate", + "slug": "homegate", + "network": "awin", + "intent_categories": "realestate_buy,realestate_rent", + "geo_countries": "CH", + "payout_type": "cpc", + "payout_amount": Decimal("0.50"), + "payout_currency": "CHF", + "description": "Switzerland's #1 real estate platform.", + "priority": 100, + }, + { + "name": "ImmoScout24", + "slug": "immoscout", + "network": "awin", + "intent_categories": "realestate_buy,realestate_rent", + "geo_countries": "CH,DE", + "payout_type": "cpc", + "payout_amount": Decimal("0.40"), + "payout_currency": "CHF", + "description": "Real estate marketplace.", + "priority": 90, + }, + { + "name": "Comparis Immo", + "slug": "comparis_immo", + "network": "direct", + "intent_categories": "realestate_buy,realestate_rent", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("15.00"), + "payout_currency": "CHF", + "description": "Property valuation and search.", + "priority": 85, + }, + + # ========================================================================= + # TRAVEL + # ========================================================================= + { + "name": "Skyscanner", + "slug": "skyscanner", + "network": "awin", + "intent_categories": "travel_flights", + "geo_countries": "CH,DE,AT", + "payout_type": "cpc", + "payout_amount": Decimal("0.30"), + "payout_currency": "CHF", + "description": "Flight comparison engine.", + "priority": 90, + }, + { + "name": "Booking.com", + "slug": "booking_com", + "network": "awin", + "intent_categories": "travel_hotels", + "geo_countries": "CH,DE,AT", + "payout_type": "cps", + "payout_amount": Decimal("4.00"), # 4% commission + "payout_currency": "CHF", + "description": "World's leading accommodation site.", + "priority": 100, + }, + + # ========================================================================= + # AUTOMOTIVE + # ========================================================================= + { + "name": "AutoScout24", + "slug": "autoscout", + "network": "awin", + "intent_categories": "auto_buy", + "geo_countries": "CH,DE", + "payout_type": "cpc", + "payout_amount": Decimal("0.60"), + "payout_currency": "CHF", + "description": "Auto marketplace.", + "priority": 100, + }, + { + "name": "Comparis Auto", + "slug": "comparis_auto", + "network": "direct", + "intent_categories": "auto_buy,auto_service", + "geo_countries": "CH", + "payout_type": "cpl", + "payout_amount": Decimal("25.00"), + "payout_currency": "CHF", + "description": "Car insurance & leasing comparison.", + "priority": 90, + }, + + # ========================================================================= + # JOBS + # ========================================================================= + { + "name": "Jobs.ch", + "slug": "jobs_ch", + "network": "awin", + "intent_categories": "jobs", + "geo_countries": "CH", + "payout_type": "cpc", + "payout_amount": Decimal("0.40"), + "payout_currency": "CHF", + "description": "Swiss job board.", + "priority": 100, + }, + { + "name": "Indeed", + "slug": "indeed", + "network": "awin", + "intent_categories": "jobs", + "geo_countries": "CH,DE,AT", + "payout_type": "cpc", + "payout_amount": Decimal("0.25"), + "payout_currency": "CHF", + "description": "Global job search engine.", + "priority": 80, + }, + + # ========================================================================= + # EDUCATION + # ========================================================================= + { + "name": "Udemy", + "slug": "udemy", + "network": "awin", + "intent_categories": "education", + "geo_countries": "CH,DE,AT", + "payout_type": "cps", + "payout_amount": Decimal("10.00"), # Per sale + "payout_currency": "USD", + "description": "Online courses platform.", + "priority": 80, + }, + + # ========================================================================= + # TECHNOLOGY / HOSTING + # ========================================================================= + { + "name": "Hostpoint", + "slug": "hostpoint", + "network": "partnerstack", + "intent_categories": "tech_hosting", + "geo_countries": "CH", + "payout_type": "cps", + "payout_amount": Decimal("30.00"), + "payout_currency": "CHF", + "description": "Swiss web hosting leader.", + "priority": 100, + }, + { + "name": "Infomaniak", + "slug": "infomaniak", + "network": "direct", + "intent_categories": "tech_hosting", + "geo_countries": "CH", + "payout_type": "cps", + "payout_amount": Decimal("25.00"), + "payout_currency": "CHF", + "description": "Eco-friendly Swiss hosting.", + "priority": 90, + }, + + # ========================================================================= + # SHOPPING + # ========================================================================= + { + "name": "Galaxus", + "slug": "galaxus", + "network": "awin", + "intent_categories": "shopping_general", + "geo_countries": "CH", + "payout_type": "cps", + "payout_amount": Decimal("2.00"), # 2% commission + "payout_currency": "CHF", + "description": "Switzerland's largest online shop.", + "priority": 100, + }, + { + "name": "Zalando", + "slug": "zalando", + "network": "awin", + "intent_categories": "shopping_fashion", + "geo_countries": "CH,DE,AT", + "payout_type": "cps", + "payout_amount": Decimal("8.00"), # 8% commission + "payout_currency": "CHF", + "description": "Fashion & lifestyle.", + "priority": 100, + }, + + # ========================================================================= + # FOOD / DELIVERY + # ========================================================================= + { + "name": "Uber Eats", + "slug": "uber_eats", + "network": "awin", + "intent_categories": "food_restaurant,food_delivery", + "geo_countries": "CH,DE", + "payout_type": "cps", + "payout_amount": Decimal("5.00"), + "payout_currency": "CHF", + "description": "Food delivery service.", + "priority": 90, + }, + + # ========================================================================= + # GENERIC FALLBACK + # ========================================================================= + { + "name": "Generic Affiliate", + "slug": "generic_affiliate", + "network": "internal", + "intent_categories": "generic", + "geo_countries": "CH,DE,AT", + "payout_type": "cpc", + "payout_amount": Decimal("0.10"), + "payout_currency": "CHF", + "description": "Fallback for unclassified domains - shows Pounce marketplace.", + "priority": 1, + }, +] + + +async def seed_partners(db: AsyncSession) -> int: + """ + Seed affiliate partners into database. + + Idempotent: updates existing partners by slug, creates new ones. + + Returns: + Number of partners created/updated. + """ + count = 0 + + for data in PARTNER_SEED_DATA: + slug = data["slug"] + + # Check if partner exists + result = await db.execute( + select(AffiliatePartner).where(AffiliatePartner.slug == slug) + ) + existing = result.scalar_one_or_none() + + if existing: + # Update existing partner + for key, value in data.items(): + setattr(existing, key, value) + logger.info(f"Updated partner: {slug}") + else: + # Create new partner + partner = AffiliatePartner(**data) + db.add(partner) + logger.info(f"Created partner: {slug}") + + count += 1 + + await db.commit() + logger.info(f"Seeded {count} affiliate partners") + return count + + +async def main(): + """Run seed script standalone.""" + logging.basicConfig(level=logging.INFO) + + async with AsyncSessionLocal() as db: + count = await seed_partners(db) + print(f"✅ Seeded {count} affiliate partners") + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/scripts/seed_yield_partners.py b/backend/scripts/seed_yield_partners.py new file mode 100644 index 0000000..8d2aff3 --- /dev/null +++ b/backend/scripts/seed_yield_partners.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +""" +CLI script to seed yield affiliate partners. + +Run from project root: + python -m scripts.seed_yield_partners + +Or with poetry/venv: + cd backend && python scripts/seed_yield_partners.py +""" + +import asyncio +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from app.database import AsyncSessionLocal +from app.seeds.yield_partners import seed_partners + + +async def main(): + print("🌱 Seeding Yield affiliate partners...") + + async with AsyncSessionLocal() as db: + count = await seed_partners(db) + print(f"✅ Successfully seeded {count} affiliate partners") + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/frontend/src/app/yield/page.tsx b/frontend/src/app/yield/page.tsx new file mode 100644 index 0000000..229484c --- /dev/null +++ b/frontend/src/app/yield/page.tsx @@ -0,0 +1,449 @@ +'use client' + +import { useState, useEffect } from 'react' +import Link from 'next/link' +import { Header } from '@/components/Header' +import { Footer } from '@/components/Footer' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { + Coins, + TrendingUp, + Shield, + Target, + ArrowRight, + Check, + Sparkles, + DollarSign, + Globe, + Zap, + BarChart3, + ChevronRight, + Play, + Server, +} from 'lucide-react' + +// Intent categories for the demo +const INTENT_EXAMPLES = [ + { domain: 'zahnarzt-zuerich.ch', intent: 'Medical / Dental', potential: 'High', revenue: '120-350' }, + { domain: 'hypothek-vergleich.ch', intent: 'Finance / Mortgage', potential: 'High', revenue: '200-500' }, + { domain: 'rechtsanwalt-bern.ch', intent: 'Legal', potential: 'High', revenue: '150-400' }, + { domain: 'wohnung-mieten.ch', intent: 'Real Estate', potential: 'Medium', revenue: '50-150' }, + { domain: 'flug-buchen.ch', intent: 'Travel', potential: 'Medium', revenue: '30-100' }, +] + +export default function YieldPublicPage() { + const { isAuthenticated, checkAuth, isLoading } = useStore() + const [analyzeDomain, setAnalyzeDomain] = useState('') + const [analyzing, setAnalyzing] = useState(false) + const [analysis, setAnalysis] = useState(null) + const [selectedExample, setSelectedExample] = useState(0) + + useEffect(() => { + checkAuth() + }, [checkAuth]) + + // Rotate through examples + useEffect(() => { + const interval = setInterval(() => { + setSelectedExample(prev => (prev + 1) % INTENT_EXAMPLES.length) + }, 4000) + return () => clearInterval(interval) + }, []) + + const handleAnalyze = async () => { + if (!analyzeDomain.trim()) return + + setAnalyzing(true) + try { + const result = await api.analyzeYieldDomain(analyzeDomain.trim()) + setAnalysis(result) + } catch (err) { + console.error('Analysis failed:', err) + } finally { + setAnalyzing(false) + } + } + + const currentExample = INTENT_EXAMPLES[selectedExample] + + return ( +
+
+ + {/* Hero Section */} +
+ {/* Background Effects */} +
+
+
+
+ +
+
+ {/* Badge */} +
+ + New Feature +
+ +

+ Turn Parked Domains Into + + Passive Income + +

+ +

+ Pounce Yield detects the intent behind your domain and routes visitors + to relevant affiliate partners. You earn 70% of every lead. +

+ + {/* CTA Buttons */} +
+ + + Start Earning + + + +
+ + {/* Trust Indicators */} +
+
+ + DNS Verified +
+
+ + 70% Revenue Share +
+
+ + Swiss Partners +
+
+
+
+
+ + {/* Live Demo Section */} +
+
+
+ Live Demo +

+ Analyze Your Domain's Yield Potential +

+
+ + {/* Domain Analyzer */} +
+
+
+ setAnalyzeDomain(e.target.value)} + placeholder="Enter your domain (e.g. zahnarzt-zuerich.ch)" + className="flex-1 px-4 py-3 bg-background-secondary border border-border rounded-xl + text-foreground placeholder:text-foreground-subtle focus:outline-none + focus:border-accent transition-colors" + onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()} + /> + +
+ + {/* Analysis Result */} + {analysis && ( +
+
+
+

{analysis.domain}

+

+ {analysis.intent.category.replace('_', ' ')} + {analysis.intent.subcategory && ` / ${analysis.intent.subcategory.replace('_', ' ')}`} +

+
+ + {analysis.monetization_potential.toUpperCase()} Potential + +
+ +
+
+

Estimated Monthly Revenue

+

+ {analysis.value.currency} {analysis.value.estimated_monthly_min} - {analysis.value.estimated_monthly_max} +

+
+
+

Intent Confidence

+
+
+
+
+ + {Math.round(analysis.intent.confidence * 100)}% + +
+
+
+ + {/* Keywords */} + {analysis.intent.keywords_matched.length > 0 && ( +
+

Detected Keywords

+
+ {analysis.intent.keywords_matched.slice(0, 5).map((kw: string, i: number) => ( + + {kw.split('~')[0]} + + ))} +
+
+ )} + + + Activate This Domain + + +
+ )} + + {/* Example Carousel */} + {!analysis && ( +
+

Examples:

+
+ {INTENT_EXAMPLES.map((ex, i) => ( + + ))} +
+
+ )} +
+
+
+
+ + {/* How It Works */} +
+
+
+ How It Works +

+ Three Steps to Passive Income +

+
+ +
+ {/* Step 1 */} +
+
+ 1 +
+
+ +
+

Add Your Domain

+

+ Enter any domain you own. Our AI analyzes the name and detects the + user intent (dental, finance, travel, etc.) +

+
+ + {/* Step 2 */} +
+
+ 2 +
+
+ +
+

Point Your DNS

+

+ Update your nameservers or add a CNAME record. We verify ownership + and activate the domain for yield. +

+
+ + {/* Step 3 */} +
+
+ 3 +
+
+ +
+

Earn Automatically

+

+ Visitors are routed to relevant partners. You earn 70% of every click, + lead, or sale. Paid out monthly. +

+
+
+
+
+ + {/* Features Grid */} +
+
+
+ Features +

+ Why Pounce Yield? +

+
+ +
+ {[ + { + icon: Target, + title: 'AI Intent Detection', + desc: 'Our engine analyzes domain names to detect user intent across 20+ categories.', + }, + { + icon: Shield, + title: 'Swiss Partners', + desc: 'Comparis, Homegate, Jobs.ch and more. Premium partners, premium payouts.', + }, + { + icon: BarChart3, + title: 'Real-Time Analytics', + desc: 'Track clicks, conversions, and revenue in your dashboard. Full transparency.', + }, + { + icon: DollarSign, + title: '70% Revenue Share', + desc: 'The highest revenue share in the industry. You keep the lion\'s share.', + }, + { + icon: Zap, + title: 'Instant Activation', + desc: 'DNS verified in minutes. Start earning the same day.', + }, + { + icon: TrendingUp, + title: 'Monthly Payouts', + desc: 'Reliable monthly payments via Stripe or bank transfer.', + }, + ].map((feature, i) => ( +
+ +

{feature.title}

+

{feature.desc}

+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+

+ Ready to Monetize Your Domains? +

+

+ Join domain investors who are turning their parked domains into passive income streams. +

+
+ + + Get Started Free + + +
+
+
+ +
+ ) +} + diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index ddee34a..b2c1a1e 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -10,6 +10,7 @@ import { Gavel, CreditCard, LayoutDashboard, + Coins, } from 'lucide-react' import { useState, useEffect } from 'react' import clsx from 'clsx' @@ -37,10 +38,11 @@ export function Header() { const tierName = subscription?.tier_name || subscription?.tier || 'Scout' // Public navigation - same for all visitors - // Navigation: Market | Intel | Pricing (gemäß pounce_public.md) + // Navigation: Market | Intel | Yield | Pricing const publicNavItems = [ { href: '/market', label: 'Market', icon: Gavel }, { href: '/intel', label: 'Intel', icon: TrendingUp }, + { href: '/yield', label: 'Yield', icon: Coins, isNew: true }, { href: '/pricing', label: 'Pricing', icon: CreditCard }, ]