feat: complete Yield feature setup
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

Backend:
- Add yield_webhooks.py for partner callbacks (generic, Awin, batch import)
- Add yield_routing.py for domain traffic routing with landing pages
- Add DB migrations for yield table indexes
- Add seed script with 30+ Swiss/German affiliate partners
- Register all new routers in API

Frontend:
- Add public /yield landing page with live analyzer demo
- Add Yield to header navigation

Documentation:
- Complete YIELD_SETUP.md with setup guide, API reference, and troubleshooting
This commit is contained in:
yves.gugger
2025-12-12 14:52:49 +01:00
parent 76a118ddbf
commit 1705b5cc6e
10 changed files with 2086 additions and 1 deletions

256
YIELD_SETUP.md Normal file
View File

@ -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 ~^(?<domain>.+)$;
# 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

View File

@ -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"])

View File

@ -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"""
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="3;url={redirect_url}">
<title>{yield_domain.domain} - Redirecting</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%);
color: #fff;
}}
.container {{
text-align: center;
padding: 2rem;
max-width: 500px;
}}
.domain {{
font-size: 1.5rem;
font-weight: 700;
color: #10b981;
margin-bottom: 0.5rem;
}}
.intent {{
font-size: 0.875rem;
color: #6b7280;
text-transform: capitalize;
margin-bottom: 2rem;
}}
.message {{
font-size: 1.125rem;
color: #d1d5db;
margin-bottom: 1rem;
}}
.partner {{
font-size: 0.875rem;
color: #9ca3af;
margin-bottom: 2rem;
}}
.spinner {{
width: 40px;
height: 40px;
border: 3px solid #1f2937;
border-top-color: #10b981;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 1.5rem;
}}
@keyframes spin {{
to {{ transform: rotate(360deg); }}
}}
.link {{
color: #10b981;
text-decoration: none;
}}
.link:hover {{
text-decoration: underline;
}}
.footer {{
position: fixed;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
color: #4b5563;
}}
.footer a {{
color: #6b7280;
text-decoration: none;
}}
</style>
</head>
<body>
<div class="container">
<div class="domain">{yield_domain.domain}</div>
<div class="intent">{intent.category.replace('_', ' ')}</div>
<div class="spinner"></div>
<div class="message">Redirecting to {partner_name}...</div>
<div class="partner">{partner_desc}</div>
<p>
<a href="{redirect_url}" class="link">Click here if not redirected</a>
</p>
</div>
<div class="footer">
Powered by <a href="{settings.site_url}">Pounce</a> •
<a href="{settings.site_url}/privacy">Privacy</a>
</div>
<script>
// Redirect after 2 seconds
setTimeout(function() {{
window.location.href = "{redirect_url}";
}}, 2000);
</script>
</body>
</html>
"""
@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"""
<html>
<head><title>Domain Not Active</title></head>
<body style="font-family: sans-serif; text-align: center; padding: 50px;">
<h1>Domain Not Active</h1>
<p>The domain <strong>{domain}</strong> is not currently active for yield routing.</p>
<p><a href="{settings.site_url}">Visit Pounce</a></p>
</body>
</html>
""",
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"<h1>Domain {host} not configured</h1>",
status_code=404
)
# Redirect to routing endpoint
return RedirectResponse(
url=f"/api/v1/r/{host}",
status_code=302
)

View File

@ -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
)

View File

@ -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")

View File

@ -0,0 +1,6 @@
"""Database seed scripts."""
from app.seeds.yield_partners import seed_partners
__all__ = ["seed_partners"]

View File

@ -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())

View File

@ -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())

View File

@ -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<any>(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 (
<div className="min-h-screen bg-background flex flex-col">
<Header />
{/* Hero Section */}
<section className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 overflow-hidden">
{/* Background Effects */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-20 left-1/4 w-[600px] h-[600px] bg-purple-500/10 rounded-full blur-[120px]" />
<div className="absolute bottom-20 right-1/4 w-[500px] h-[500px] bg-emerald-500/10 rounded-full blur-[100px]" />
</div>
<div className="relative max-w-7xl mx-auto">
<div className="text-center max-w-3xl mx-auto">
{/* Badge */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm mb-8">
<Sparkles className="w-4 h-4" />
<span>New Feature</span>
</div>
<h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
Turn Parked Domains Into
<span className="block text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 via-purple-400 to-emerald-400">
Passive Income
</span>
</h1>
<p className="mt-6 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
Pounce Yield detects the intent behind your domain and routes visitors
to relevant affiliate partners. <span className="text-foreground">You earn 70% of every lead.</span>
</p>
{/* CTA Buttons */}
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<Link
href={isAuthenticated ? '/terminal/yield' : '/register?redirect=/terminal/yield'}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4
bg-gradient-to-r from-purple-500 to-emerald-500
text-white text-lg font-semibold rounded-xl
hover:from-purple-400 hover:to-emerald-400 transition-all
shadow-lg shadow-purple-500/25"
>
<Coins className="w-5 h-5" />
Start Earning
<ArrowRight className="w-5 h-5" />
</Link>
<button
onClick={() => document.getElementById('demo')?.scrollIntoView({ behavior: 'smooth' })}
className="w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4
bg-background-secondary border border-border text-foreground
text-lg font-semibold rounded-xl hover:border-accent transition-all"
>
<Play className="w-5 h-5" />
See Demo
</button>
</div>
{/* Trust Indicators */}
<div className="mt-12 flex flex-wrap items-center justify-center gap-6 text-sm text-foreground-muted">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-500" />
<span>DNS Verified</span>
</div>
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-emerald-500" />
<span>70% Revenue Share</span>
</div>
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-emerald-500" />
<span>Swiss Partners</span>
</div>
</div>
</div>
</div>
</section>
{/* Live Demo Section */}
<section id="demo" className="relative py-20 sm:py-28 px-4 sm:px-6 bg-background-secondary/50">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<span className="text-sm font-semibold text-purple-400 uppercase tracking-wider">Live Demo</span>
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
Analyze Your Domain's Yield Potential
</h2>
</div>
{/* Domain Analyzer */}
<div className="max-w-2xl mx-auto">
<div className="bg-background border border-border rounded-2xl p-6 sm:p-8 shadow-xl">
<div className="flex flex-col sm:flex-row gap-4">
<input
type="text"
value={analyzeDomain}
onChange={(e) => 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()}
/>
<button
onClick={handleAnalyze}
disabled={analyzing || !analyzeDomain.trim()}
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-emerald-500
text-white font-semibold rounded-xl
hover:from-purple-400 hover:to-emerald-400 transition-all
disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{analyzing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Analyzing...
</>
) : (
<>
<Zap className="w-4 h-4" />
Analyze
</>
)}
</button>
</div>
{/* Analysis Result */}
{analysis && (
<div className="mt-8 p-6 bg-background-secondary rounded-xl border border-border">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-foreground">{analysis.domain}</h3>
<p className="text-sm text-foreground-muted capitalize">
{analysis.intent.category.replace('_', ' ')}
{analysis.intent.subcategory && ` / ${analysis.intent.subcategory.replace('_', ' ')}`}
</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
analysis.monetization_potential === 'high'
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
: analysis.monetization_potential === 'medium'
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
: 'bg-zinc-500/20 text-zinc-400 border border-zinc-500/30'
}`}>
{analysis.monetization_potential.toUpperCase()} Potential
</span>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<div className="p-4 bg-gradient-to-br from-emerald-500/10 to-transparent rounded-xl border border-emerald-500/20">
<p className="text-sm text-foreground-muted mb-1">Estimated Monthly Revenue</p>
<p className="text-2xl font-bold text-emerald-400">
{analysis.value.currency} {analysis.value.estimated_monthly_min} - {analysis.value.estimated_monthly_max}
</p>
</div>
<div className="p-4 bg-background rounded-xl border border-border">
<p className="text-sm text-foreground-muted mb-1">Intent Confidence</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-background-secondary rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-emerald-500 rounded-full"
style={{ width: `${analysis.intent.confidence * 100}%` }}
/>
</div>
<span className="text-sm font-medium text-foreground">
{Math.round(analysis.intent.confidence * 100)}%
</span>
</div>
</div>
</div>
{/* Keywords */}
{analysis.intent.keywords_matched.length > 0 && (
<div className="mt-4">
<p className="text-sm text-foreground-muted mb-2">Detected Keywords</p>
<div className="flex flex-wrap gap-2">
{analysis.intent.keywords_matched.slice(0, 5).map((kw: string, i: number) => (
<span key={i} className="px-2 py-1 bg-background rounded text-xs text-foreground-subtle">
{kw.split('~')[0]}
</span>
))}
</div>
</div>
)}
<Link
href={isAuthenticated ? '/terminal/yield' : `/register?redirect=/terminal/yield&domain=${analysis.domain}`}
className="mt-6 w-full inline-flex items-center justify-center gap-2 px-6 py-3
bg-gradient-to-r from-purple-500 to-emerald-500
text-white font-semibold rounded-xl
hover:from-purple-400 hover:to-emerald-400 transition-all"
>
Activate This Domain
<ArrowRight className="w-4 h-4" />
</Link>
</div>
)}
{/* Example Carousel */}
{!analysis && (
<div className="mt-8">
<p className="text-sm text-foreground-muted mb-4">Examples:</p>
<div className="space-y-2">
{INTENT_EXAMPLES.map((ex, i) => (
<button
key={ex.domain}
onClick={() => {
setAnalyzeDomain(ex.domain)
setSelectedExample(i)
}}
className={`w-full p-4 rounded-xl border text-left transition-all ${
i === selectedExample
? 'bg-purple-500/10 border-purple-500/30'
: 'bg-background-secondary border-border hover:border-accent/30'
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-foreground">{ex.domain}</p>
<p className="text-sm text-foreground-muted">{ex.intent}</p>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
ex.potential === 'High' ? 'text-emerald-400' : 'text-amber-400'
}`}>
{ex.potential}
</p>
<p className="text-xs text-foreground-muted">CHF {ex.revenue}/mo</p>
</div>
</div>
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>
</section>
{/* How It Works */}
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">How It Works</span>
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
Three Steps to Passive Income
</h2>
</div>
<div className="grid md:grid-cols-3 gap-8">
{/* Step 1 */}
<div className="relative p-8 bg-background-secondary border border-border rounded-2xl">
<div className="absolute -top-4 left-8 w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center text-white font-bold">
1
</div>
<div className="w-14 h-14 bg-purple-500/10 rounded-xl flex items-center justify-center mb-6">
<Globe className="w-7 h-7 text-purple-400" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-3">Add Your Domain</h3>
<p className="text-foreground-muted">
Enter any domain you own. Our AI analyzes the name and detects the
user intent (dental, finance, travel, etc.)
</p>
</div>
{/* Step 2 */}
<div className="relative p-8 bg-background-secondary border border-border rounded-2xl md:-translate-y-4">
<div className="absolute -top-4 left-8 w-8 h-8 bg-emerald-500 rounded-full flex items-center justify-center text-white font-bold">
2
</div>
<div className="w-14 h-14 bg-emerald-500/10 rounded-xl flex items-center justify-center mb-6">
<Server className="w-7 h-7 text-emerald-400" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-3">Point Your DNS</h3>
<p className="text-foreground-muted">
Update your nameservers or add a CNAME record. We verify ownership
and activate the domain for yield.
</p>
</div>
{/* Step 3 */}
<div className="relative p-8 bg-background-secondary border border-border rounded-2xl">
<div className="absolute -top-4 left-8 w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center text-white font-bold">
3
</div>
<div className="w-14 h-14 bg-purple-500/10 rounded-xl flex items-center justify-center mb-6">
<DollarSign className="w-7 h-7 text-purple-400" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-3">Earn Automatically</h3>
<p className="text-foreground-muted">
Visitors are routed to relevant partners. You earn 70% of every click,
lead, or sale. Paid out monthly.
</p>
</div>
</div>
</div>
</section>
{/* Features Grid */}
<section className="relative py-20 sm:py-28 px-4 sm:px-6 bg-background-secondary/50">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-16">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Features</span>
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
Why Pounce Yield?
</h2>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{
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) => (
<div key={i} className="p-6 bg-background border border-border rounded-xl hover:border-accent/30 transition-all">
<feature.icon className="w-8 h-8 text-accent mb-4" />
<h3 className="text-lg font-semibold text-foreground mb-2">{feature.title}</h3>
<p className="text-foreground-muted text-sm">{feature.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
<div className="max-w-4xl mx-auto text-center">
<h2 className="font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
Ready to Monetize Your Domains?
</h2>
<p className="mt-6 text-lg text-foreground-muted max-w-2xl mx-auto">
Join domain investors who are turning their parked domains into passive income streams.
</p>
<div className="mt-10">
<Link
href={isAuthenticated ? '/terminal/yield' : '/register?redirect=/terminal/yield'}
className="inline-flex items-center justify-center gap-2 px-10 py-5
bg-gradient-to-r from-purple-500 to-emerald-500
text-white text-xl font-semibold rounded-xl
hover:from-purple-400 hover:to-emerald-400 transition-all
shadow-xl shadow-purple-500/25"
>
<Coins className="w-6 h-6" />
Get Started Free
<ArrowRight className="w-6 h-6" />
</Link>
</div>
</div>
</section>
<Footer />
</div>
)
}

View File

@ -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 },
]