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
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:
256
YIELD_SETUP.md
Normal file
256
YIELD_SETUP.md
Normal 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
|
||||
|
||||
@ -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"])
|
||||
|
||||
381
backend/app/api/yield_routing.py
Normal file
381
backend/app/api/yield_routing.py
Normal 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
|
||||
)
|
||||
|
||||
457
backend/app/api/yield_webhooks.py
Normal file
457
backend/app/api/yield_webhooks.py
Normal 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
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
6
backend/app/seeds/__init__.py
Normal file
6
backend/app/seeds/__init__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Database seed scripts."""
|
||||
|
||||
from app.seeds.yield_partners import seed_partners
|
||||
|
||||
__all__ = ["seed_partners"]
|
||||
|
||||
458
backend/app/seeds/yield_partners.py
Normal file
458
backend/app/seeds/yield_partners.py
Normal 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())
|
||||
|
||||
33
backend/scripts/seed_yield_partners.py
Normal file
33
backend/scripts/seed_yield_partners.py
Normal 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())
|
||||
|
||||
449
frontend/src/app/yield/page.tsx
Normal file
449
frontend/src/app/yield/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user