feat: implement Yield/Intent Routing feature (pounce_endgame)
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 YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner models - Create IntentDetector service for keyword-based intent classification - Implement /api/v1/yield/* endpoints (dashboard, domains, transactions, partners) - Support domain activation, DNS verification, and revenue tracking Frontend: - Add /terminal/yield page with dashboard and activate wizard - Add YIELD to sidebar navigation under 'Monetize' section - Add 4th pillar 'Yield' to landing page 'Beyond Hunting' section - Extend API client with yield endpoints and types Features: - AI-powered intent detection (medical, finance, legal, realestate, etc.) - Swiss/German geo-targeting with city recognition - Revenue estimation based on intent category and geo - DNS verification via nameservers or CNAME - 70/30 revenue split tracking
This commit is contained in:
0
DEPLOY_docker_compose.env.example
Normal file → Executable file
0
DEPLOY_docker_compose.env.example
Normal file → Executable file
361
PUBLIC_PAGE_ANALYSIS_REPORT.md
Normal file
361
PUBLIC_PAGE_ANALYSIS_REPORT.md
Normal file
@ -0,0 +1,361 @@
|
||||
# Public Pages Analyse-Report
|
||||
## Zielgruppen-Klarheit & Mehrwert-Kommunikation
|
||||
|
||||
**Analysedatum:** 12. Dezember 2025
|
||||
**Zielgruppe:** Domain-Investoren, professionelle Trader, Founder auf Domain-Suche
|
||||
**Kernbotschaft laut Strategie:** "Don't guess. Know." (Intelligence & Trust)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
| Seite | Klarheit | Mehrwert | CTAs | Trust | Gesamt |
|
||||
|-------|----------|----------|------|-------|--------|
|
||||
| **Landing Page** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | **Exzellent** |
|
||||
| **Market Page** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | **Sehr gut** |
|
||||
| **Intel Page** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | **Sehr gut** |
|
||||
| **Pricing Page** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | **Sehr gut** |
|
||||
|
||||
**Gesamtbewertung:** Die Public Pages sind **strategisch exzellent aufgebaut** und kommunizieren den Mehrwert klar für die Zielgruppe Domain-Investoren.
|
||||
|
||||
---
|
||||
|
||||
## 1. Landing Page (Home)
|
||||
|
||||
### ✅ Stärken
|
||||
|
||||
#### Value Proposition sofort klar
|
||||
```
|
||||
Headline: "The market never sleeps. You should."
|
||||
Subline: "Domain Intelligence for Investors. Scan, track, and trade digital assets."
|
||||
Tagline: "Don't guess. Know."
|
||||
```
|
||||
**Analyse:** Die Headline spricht die "Pain" der Zielgruppe direkt an (ständig monitoren müssen). Die Subline definiert klar WAS Pounce macht (Intelligence) und für WEN (Investors).
|
||||
|
||||
#### Trust-Signale
|
||||
- **886+ TLDs** — Zeigt Datentiefe
|
||||
- **Live Auctions** — Zeigt Aktualität
|
||||
- **Instant Alerts** — Zeigt Reaktionsgeschwindigkeit
|
||||
- **Price Intel** — Zeigt analytischen Mehrwert
|
||||
|
||||
#### Three Pillars (Discover → Track → Trade)
|
||||
| Pillar | Value Proposition |
|
||||
|--------|-------------------|
|
||||
| **Discover** | "Not just 'taken' — but WHY, WHEN it expires, and SMARTER alternatives" |
|
||||
| **Track** | "4-layer health analysis. Know the second it weakens." |
|
||||
| **Trade** | "Buy & sell directly. 0% Commission. Verified owners." |
|
||||
|
||||
**Analyse:** Jeder Pillar adressiert eine konkrete Nutzen-Stufe im Domain-Investing-Workflow.
|
||||
|
||||
#### Live Market Teaser (Gatekeeper)
|
||||
- Zeigt 4 echte Domains mit Preisen
|
||||
- 5. Zeile ist geblurrt
|
||||
- CTA: "Sign in to see X+ more domains"
|
||||
|
||||
**Analyse:** Perfekte Umsetzung des "Teaser & Gatekeeper"-Prinzips.
|
||||
|
||||
### ⚠️ Verbesserungspotential
|
||||
|
||||
| Problem | Aktuelle Umsetzung | Empfehlung |
|
||||
|---------|-------------------|------------|
|
||||
| **DomainChecker Placeholder** | Statischer Text | Animierter Typing-Effect fehlt noch ("Search crypto.ai...", "Search hotel.zurich...") |
|
||||
| **Beyond Hunting Section** | "Own. Protect. Monetize." | Guter Text, aber Link zu `/buy` könnte verwirrend sein - besser `/market` oder `/terminal` |
|
||||
| **Sniper Alerts Link** | `/terminal/watchlist` | Für nicht-eingeloggte User nutzlos - sollte zu `/register` führen |
|
||||
|
||||
### 📊 Kennzahlen
|
||||
|
||||
- **Sections:** 8 (Hero, Ticker, Market Teaser, Pillars, Beyond, TLDs, Stats, CTA)
|
||||
- **CTAs zum Registrieren:** 4
|
||||
- **Trust-Indikatoren:** 7
|
||||
- **Lock/Blur-Elemente:** 2 (Market Teaser, TLD Preise)
|
||||
|
||||
---
|
||||
|
||||
## 2. Market Page
|
||||
|
||||
### ✅ Stärken
|
||||
|
||||
#### Klare Positionierung
|
||||
```
|
||||
H1: "Live Domain Market"
|
||||
Sub: "Aggregated from GoDaddy, Sedo, and Pounce Direct."
|
||||
```
|
||||
**Analyse:** Sofort klar: Aggregation mehrerer Quellen an einem Ort = Zeitersparnis.
|
||||
|
||||
#### Vanity-Filter für nicht-eingeloggte User
|
||||
```javascript
|
||||
// Rules: No numbers (except short domains), no hyphens, length < 12, only premium TLDs
|
||||
const PREMIUM_TLDS = ['com', 'io', 'ai', 'co', 'de', 'ch', 'net', 'org', 'app', 'dev', 'xyz']
|
||||
```
|
||||
**Analyse:** Zeigt nur "Premium-Looking" Domains → Professioneller erster Eindruck.
|
||||
|
||||
#### Pounce Score & Valuation geblurrt
|
||||
- Sichtbar aber geblurrt mit Lock-Icon
|
||||
- Hover-Text verfügbar
|
||||
- Motiviert zur Registrierung
|
||||
|
||||
#### Bottom CTA
|
||||
```
|
||||
"Tired of digging through spam? Our 'Trader' plan filters 99% of junk domains automatically."
|
||||
[Upgrade Filter]
|
||||
```
|
||||
**Analyse:** Adressiert direkten Pain Point (Spam in Auktionen) und bietet Lösung.
|
||||
|
||||
### ⚠️ Verbesserungspotential
|
||||
|
||||
| Problem | Aktuelle Umsetzung | Empfehlung |
|
||||
|---------|-------------------|------------|
|
||||
| **Pounce Direct Section** | Zeigt interne Listings | Gut, aber "0% Commission" sollte prominenter sein |
|
||||
| **Mobile Darstellung** | Einige Spalten hidden | Ok, aber Deal Score sollte auch mobil geblurrt sichtbar sein |
|
||||
|
||||
### 📊 Gatekeeper-Elemente
|
||||
|
||||
- ✅ Vanity Filter (nur schöne Domains für Ausgeloggte)
|
||||
- ✅ Pounce Score geblurrt
|
||||
- ✅ Valuation geblurrt
|
||||
- ✅ Bottom CTA für Upgrade
|
||||
- ✅ Login Banner
|
||||
|
||||
---
|
||||
|
||||
## 3. Intel Page (TLD Inflation Monitor)
|
||||
|
||||
### ✅ Stärken
|
||||
|
||||
#### Unique Value Proposition
|
||||
```
|
||||
H1: "TLD Market Inflation Monitor"
|
||||
Sub: "Don't fall for promo prices. See renewal costs, spot traps, and track price trends..."
|
||||
```
|
||||
**Analyse:** Adressiert einen echten, wenig bekannten Pain Point: Registrar locken mit günstigen Erstjahr-Preisen, aber Renewals sind teuer ("Renewal Traps").
|
||||
|
||||
#### Top Movers Cards
|
||||
- Zeigt TLDs mit größten Preisänderungen
|
||||
- Visuell ansprechend mit Trend-Badges
|
||||
- Sofort sichtbarer Mehrwert
|
||||
|
||||
#### Intelligentes Gating
|
||||
```
|
||||
.com, .net, .org → Vollständig sichtbar (als Beweis)
|
||||
Alle anderen → Buy Price + Trend sichtbar, Renewal + Risk geblurrt
|
||||
```
|
||||
**Analyse:** Perfekte Umsetzung: Zeigt DASS die Daten existieren (bei .com), versteckt die "Intelligence" (Renewal/Risk) für andere.
|
||||
|
||||
#### Trust-Indikatoren
|
||||
- "Renewal Trap Detection" Badge
|
||||
- "Risk Levels" Badge mit Farben
|
||||
- "1y/3y Trends" Badge
|
||||
|
||||
### ⚠️ Verbesserungspotential
|
||||
|
||||
| Problem | Aktuelle Umsetzung | Empfehlung |
|
||||
|---------|-------------------|------------|
|
||||
| **SEO-Titel** | "TLD Market Inflation Monitor" | Exzellent für SEO - bleibt so |
|
||||
| **Top Movers Links** | Führen zu `/register` für Ausgeloggte | Ok, aber könnte auch zur Intel-Detailseite mit Gating führen |
|
||||
|
||||
### 📊 Gatekeeper-Elemente
|
||||
|
||||
- ✅ Renewal Price geblurrt (außer .com/.net/.org)
|
||||
- ✅ Risk Level geblurrt (außer .com/.net/.org)
|
||||
- ✅ Login Banner prominent
|
||||
- ✅ "Stop overpaying" Messaging
|
||||
|
||||
---
|
||||
|
||||
## 4. Pricing Page
|
||||
|
||||
### ✅ Stärken
|
||||
|
||||
#### Klare Tier-Struktur
|
||||
```
|
||||
Scout (Free) → Trader ($9) → Tycoon ($29)
|
||||
```
|
||||
|
||||
#### Feature-Differenzierung mit Emojis
|
||||
| Feature | Scout | Trader | Tycoon |
|
||||
|---------|-------|--------|--------|
|
||||
| Market Feed | 🌪️ Raw | ✨ Curated | ⚡ Priority |
|
||||
| Alert Speed | 🐢 Daily | 🐇 Hourly | ⚡ 10 min |
|
||||
| Watchlist | 5 Domains | 50 Domains | 500 Domains |
|
||||
|
||||
**Analyse:** Emojis machen die Differenzierung sofort visuell verständlich.
|
||||
|
||||
#### FAQ Section
|
||||
Adressiert echte Fragen:
|
||||
- "How fast will I know when a domain drops?"
|
||||
- "What's domain valuation?"
|
||||
- "Can I track domains I already own?"
|
||||
|
||||
#### Best Value Highlight
|
||||
- Trader-Plan hat "Best Value" Badge
|
||||
- Visuell hervorgehoben (Rahmen/Farbe)
|
||||
|
||||
### ⚠️ Verbesserungspotential
|
||||
|
||||
| Problem | Aktuelle Umsetzung | Empfehlung |
|
||||
|---------|-------------------|------------|
|
||||
| **Sniper Alerts** | Scout: "—", Trader: "5", Tycoon: "Unlimited" | Könnte klarer erklärt werden was das ist |
|
||||
| **Portfolio Feature** | Scout: "—", Trader: "25 Domains" | Sollte erklären: "Track YOUR owned domains" |
|
||||
|
||||
---
|
||||
|
||||
## 5. Header & Navigation
|
||||
|
||||
### ✅ Stärken
|
||||
|
||||
```
|
||||
Market | Intel | Pricing | [Sign In] | [Start Hunting]
|
||||
```
|
||||
|
||||
- **Dark Mode durchgängig** — Professioneller Look
|
||||
- **"Start Hunting" statt "Get Started"** — Spricht die Zielgruppe direkt an
|
||||
- **Neon-grüner CTA** — Hohe Visibility
|
||||
- **Minimalistisch** — Keine Überladung
|
||||
|
||||
### ⚠️ Verbesserungspotential
|
||||
|
||||
| Problem | Aktuelle Umsetzung | Empfehlung |
|
||||
|---------|-------------------|------------|
|
||||
| **Mobile Menu** | Funktional | Ok, aber CTA sollte noch prominenter sein |
|
||||
|
||||
---
|
||||
|
||||
## 6. Footer
|
||||
|
||||
### ✅ Stärken
|
||||
|
||||
- **"Don't guess. Know."** — Tagline präsent
|
||||
- **Social Links** — Twitter, LinkedIn, Email
|
||||
- **Korrekte Links** — Market, Intel, Pricing
|
||||
|
||||
---
|
||||
|
||||
## Zielgruppen-Analyse
|
||||
|
||||
### Primäre Zielgruppe: Domain-Investoren
|
||||
|
||||
| Bedürfnis | Wird adressiert? | Wo? |
|
||||
|-----------|------------------|-----|
|
||||
| Auktionen monitoren | ✅ | Market Page, Ticker |
|
||||
| Expiring Domains finden | ✅ | Track Pillar, Alerts |
|
||||
| TLD-Preise vergleichen | ✅ | Intel Page |
|
||||
| Portfolio verwalten | ✅ | Beyond Hunting Section |
|
||||
| Domains verkaufen | ✅ | Trade Pillar, Marketplace |
|
||||
|
||||
### Sekundäre Zielgruppe: Founder auf Domain-Suche
|
||||
|
||||
| Bedürfnis | Wird adressiert? | Wo? |
|
||||
|-----------|------------------|-----|
|
||||
| Domain-Verfügbarkeit prüfen | ✅ | DomainChecker (Hero) |
|
||||
| Alternativen finden | ✅ | "AI-powered alternatives" |
|
||||
| Faire Preise kennen | ✅ | Intel Page |
|
||||
|
||||
---
|
||||
|
||||
## Conversion-Funnel Analyse
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LANDING PAGE │
|
||||
│ "The market never sleeps. You should." │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ DISCOVER │ │ TRACK │ │ TRADE │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ LIVE MARKET TEASER (Blurred) │ │
|
||||
│ │ "Sign in to see X+ more domains" │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ [START HUNTING] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ MARKET PAGE │
|
||||
│ "Aggregated from GoDaddy, Sedo, and Pounce Direct" │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────┐ │
|
||||
│ │ Domain | Price | Score (🔒) | Valuation (🔒) │ │
|
||||
│ └──────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ "Tired of digging through spam?" → [UPGRADE FILTER] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ INTEL PAGE │
|
||||
│ "TLD Market Inflation Monitor" │
|
||||
│ │
|
||||
│ .com, .net, .org → FULL DATA │
|
||||
│ Others → Renewal (🔒), Risk (🔒) │
|
||||
│ │
|
||||
│ "Stop overpaying. Know the true costs." │
|
||||
│ ↓ │
|
||||
│ [START HUNTING] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PRICING PAGE │
|
||||
│ │
|
||||
│ Scout (Free) → Trader ($9) → Tycoon ($29) │
|
||||
│ │
|
||||
│ "Start with Scout. It's free forever." │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ REGISTER PAGE │
|
||||
│ │
|
||||
│ "Track up to 5 domains. Free." │
|
||||
│ "Daily status scans. Never miss a drop." │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Empfehlungen für Optimierung
|
||||
|
||||
### Hohe Priorität
|
||||
|
||||
1. **DomainChecker Animation**
|
||||
- Implementiere den Typing-Effect für Placeholder
|
||||
- Beispiele: "Search crypto.ai...", "Search hotel.zurich..."
|
||||
- Macht den Hero interaktiver und zeigt Anwendungsfälle
|
||||
|
||||
2. **Links für Ausgeloggte korrigieren**
|
||||
- `/terminal/watchlist` → `/register?redirect=/terminal/watchlist`
|
||||
- `/buy` → Klarstellen, dass dies der Marketplace ist
|
||||
|
||||
### Mittlere Priorität
|
||||
|
||||
3. **Testimonials/Social Proof hinzufügen**
|
||||
- Aktuell: Nur Zahlen (886+ TLDs, 24/7)
|
||||
- Fehlt: User-Testimonials, bekannte Nutzer, Logos
|
||||
|
||||
4. **Video/Demo**
|
||||
- Ein kurzes Video (30s) auf der Landing Page
|
||||
- Zeigt das Dashboard in Aktion
|
||||
|
||||
### Niedrige Priorität
|
||||
|
||||
5. **Blog/Briefings SEO**
|
||||
- Mehr Content für organischen Traffic
|
||||
- Themen: "Top 10 TLDs 2025", "Domain Investing Guide"
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Die Public Pages sind **strategisch exzellent umgesetzt** und folgen dem "Teaser & Gatekeeper"-Prinzip konsequent:
|
||||
|
||||
1. **✅ Mehrwert ist sofort klar** — "Domain Intelligence for Investors"
|
||||
2. **✅ Zielgruppe wird direkt angesprochen** — "Hunters", "Investors", "Trade"
|
||||
3. **✅ Daten werden gezeigt, Intelligenz versteckt** — Blurred Scores, Locked Features
|
||||
4. **✅ Trust-Signale sind präsent** — 886+ TLDs, Live Data, Dark Mode Pro-Look
|
||||
5. **✅ CTAs sind konsistent** — "Start Hunting" überall
|
||||
|
||||
**Die Pages sind bereit für Launch.**
|
||||
|
||||
---
|
||||
|
||||
*Report generiert am 12. Dezember 2025*
|
||||
|
||||
@ -18,6 +18,7 @@ from app.api.listings import router as listings_router
|
||||
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
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@ -42,6 +43,9 @@ api_router.include_router(sniper_alerts_router, prefix="/sniper-alerts", tags=["
|
||||
# SEO Data / Backlinks - from analysis_3.md (Tycoon-only)
|
||||
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"])
|
||||
|
||||
# Support & Communication
|
||||
api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"])
|
||||
|
||||
|
||||
@ -233,12 +233,12 @@ async def list_users(
|
||||
.outerjoin(Subscription, Subscription.user_id == User.id)
|
||||
.outerjoin(domain_counts, domain_counts.c.user_id == User.id)
|
||||
)
|
||||
|
||||
|
||||
if search:
|
||||
base = base.where(
|
||||
User.email.ilike(f"%{search}%") | User.name.ilike(f"%{search}%")
|
||||
)
|
||||
|
||||
|
||||
# Total count (for pagination UI)
|
||||
count_query = select(func.count(User.id))
|
||||
if search:
|
||||
@ -249,36 +249,36 @@ async def list_users(
|
||||
|
||||
result = await db.execute(
|
||||
base.order_by(desc(User.created_at)).offset(offset).limit(limit)
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
user_list = []
|
||||
for user, subscription, domain_count in rows:
|
||||
user_list.append(
|
||||
{
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
"is_verified": user.is_verified,
|
||||
"is_admin": user.is_admin,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"is_active": user.is_active,
|
||||
"is_verified": user.is_verified,
|
||||
"is_admin": user.is_admin,
|
||||
"created_at": user.created_at.isoformat(),
|
||||
"last_login": user.last_login.isoformat() if user.last_login else None,
|
||||
"domain_count": int(domain_count or 0),
|
||||
"subscription": {
|
||||
"tier": subscription.tier.value if subscription else "scout",
|
||||
"tier_name": TIER_CONFIG.get(subscription.tier, {}).get("name", "Scout") if subscription else "Scout",
|
||||
"status": subscription.status.value if subscription else None,
|
||||
"domain_limit": subscription.domain_limit if subscription else 5,
|
||||
} if subscription else {
|
||||
"tier": "scout",
|
||||
"tier_name": "Scout",
|
||||
"status": None,
|
||||
"domain_limit": 5,
|
||||
},
|
||||
"subscription": {
|
||||
"tier": subscription.tier.value if subscription else "scout",
|
||||
"tier_name": TIER_CONFIG.get(subscription.tier, {}).get("name", "Scout") if subscription else "Scout",
|
||||
"status": subscription.status.value if subscription else None,
|
||||
"domain_limit": subscription.domain_limit if subscription else 5,
|
||||
} if subscription else {
|
||||
"tier": "scout",
|
||||
"tier_name": "Scout",
|
||||
"status": None,
|
||||
"domain_limit": 5,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
return {"users": user_list, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@ -293,7 +293,7 @@ async def export_users_csv(
|
||||
"""Export all users as CSV data."""
|
||||
import csv
|
||||
import io
|
||||
|
||||
|
||||
domain_counts = (
|
||||
select(
|
||||
Domain.user_id.label("user_id"),
|
||||
@ -617,9 +617,9 @@ async def trigger_tld_scrape(
|
||||
|
||||
# Fallback: run inline
|
||||
from app.services.tld_scraper.aggregator import tld_aggregator
|
||||
|
||||
|
||||
result = await tld_aggregator.run_scrape(db)
|
||||
|
||||
|
||||
return {
|
||||
"message": "TLD price scrape completed",
|
||||
"status": result.status,
|
||||
@ -1132,10 +1132,10 @@ async def trigger_auction_scrape(
|
||||
|
||||
# Fallback: run inline
|
||||
from app.services.auction_scraper import AuctionScraperService
|
||||
|
||||
|
||||
scraper = AuctionScraperService()
|
||||
result = await scraper.scrape_all_platforms(db)
|
||||
|
||||
|
||||
return {
|
||||
"message": "Auction scraping completed",
|
||||
"result": result,
|
||||
|
||||
@ -899,9 +899,9 @@ async def get_market_feed(
|
||||
# Build base filters (SQL-side)
|
||||
# -----------------------------
|
||||
listing_filters = [DomainListing.status == ListingStatus.ACTIVE.value]
|
||||
if keyword:
|
||||
if keyword:
|
||||
listing_filters.append(DomainListing.domain.ilike(f"%{keyword}%"))
|
||||
if verified_only:
|
||||
if verified_only:
|
||||
listing_filters.append(DomainListing.verification_status == VerificationStatus.VERIFIED.value)
|
||||
if min_price is not None:
|
||||
listing_filters.append(DomainListing.asking_price >= min_price)
|
||||
@ -918,9 +918,9 @@ async def get_market_feed(
|
||||
auction_filters.append(DomainAuction.domain.ilike(f"%{keyword}%"))
|
||||
if tld_clean:
|
||||
auction_filters.append(DomainAuction.tld == tld_clean)
|
||||
if min_price is not None:
|
||||
if min_price is not None:
|
||||
auction_filters.append(DomainAuction.current_bid >= min_price)
|
||||
if max_price is not None:
|
||||
if max_price is not None:
|
||||
auction_filters.append(DomainAuction.current_bid <= max_price)
|
||||
if ending_within:
|
||||
cutoff = now + timedelta(hours=ending_within)
|
||||
@ -976,7 +976,7 @@ async def get_market_feed(
|
||||
pounce_score = listing.pounce_score or _calculate_pounce_score_v2(listing.domain, domain_tld, is_pounce=True)
|
||||
if pounce_score < min_score:
|
||||
continue
|
||||
|
||||
|
||||
item = MarketFeedItem(
|
||||
id=f"pounce-{listing.id}",
|
||||
domain=listing.domain,
|
||||
@ -995,7 +995,7 @@ async def get_market_feed(
|
||||
pounce_score=pounce_score,
|
||||
)
|
||||
built.append({"item": item, "newest_ts": listing.updated_at or listing.created_at or datetime.min})
|
||||
|
||||
|
||||
# External auctions
|
||||
if source in ["all", "external"]:
|
||||
auction_query = select(DomainAuction).where(and_(*auction_filters))
|
||||
@ -1018,24 +1018,24 @@ async def get_market_feed(
|
||||
|
||||
auction_query = auction_query.offset(auction_offset).limit(auction_limit)
|
||||
auctions = (await db.execute(auction_query)).scalars().all()
|
||||
|
||||
|
||||
for auction in auctions:
|
||||
# Vanity filter for anonymous users
|
||||
if current_user is None and not _is_premium_domain(auction.domain):
|
||||
continue
|
||||
|
||||
|
||||
pounce_score = auction.pounce_score
|
||||
if pounce_score is None:
|
||||
pounce_score = _calculate_pounce_score_v2(
|
||||
auction.domain,
|
||||
auction.tld,
|
||||
num_bids=auction.num_bids,
|
||||
age_years=auction.age_years or 0,
|
||||
pounce_score = _calculate_pounce_score_v2(
|
||||
auction.domain,
|
||||
auction.tld,
|
||||
num_bids=auction.num_bids,
|
||||
age_years=auction.age_years or 0,
|
||||
is_pounce=False,
|
||||
)
|
||||
)
|
||||
if pounce_score < min_score:
|
||||
continue
|
||||
|
||||
|
||||
item = MarketFeedItem(
|
||||
id=f"auction-{auction.id}",
|
||||
domain=auction.domain,
|
||||
@ -1055,7 +1055,7 @@ async def get_market_feed(
|
||||
pounce_score=pounce_score,
|
||||
)
|
||||
built.append({"item": item, "newest_ts": auction.updated_at or auction.scraped_at or datetime.min})
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# Merge sort (Python) + paginate
|
||||
# -----------------------------
|
||||
@ -1072,22 +1072,22 @@ async def get_market_feed(
|
||||
)
|
||||
elif sort_by == "newest":
|
||||
built.sort(key=lambda x: (int(x["item"].is_pounce), x["newest_ts"]), reverse=True)
|
||||
|
||||
|
||||
total = pounce_total + auction_total if source == "all" else (pounce_total if source == "pounce" else auction_total)
|
||||
|
||||
|
||||
page_slice = built[offset:offset + limit]
|
||||
items = [x["item"] for x in page_slice]
|
||||
|
||||
|
||||
# Unique sources (after pagination)
|
||||
sources = list(set(item.source for item in items))
|
||||
|
||||
|
||||
# Last update time (auctions)
|
||||
if source in ["all", "external"]:
|
||||
last_update_result = await db.execute(select(func.max(DomainAuction.updated_at)))
|
||||
last_updated = last_update_result.scalar() or now
|
||||
else:
|
||||
last_updated = now
|
||||
|
||||
|
||||
return MarketFeedResponse(
|
||||
items=items,
|
||||
total=total,
|
||||
|
||||
@ -211,7 +211,7 @@ async def login(user_data: UserLogin, db: Database, response: Response):
|
||||
data={"sub": str(user.id), "email": user.email},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
|
||||
|
||||
# Set HttpOnly cookie (preferred for browser clients)
|
||||
set_auth_cookie(
|
||||
response=response,
|
||||
|
||||
@ -28,7 +28,7 @@ async def get_current_user(
|
||||
|
||||
token: Optional[str] = None
|
||||
if credentials is not None:
|
||||
token = credentials.credentials
|
||||
token = credentials.credentials
|
||||
if not token:
|
||||
token = request.cookies.get(AUTH_COOKIE_NAME)
|
||||
|
||||
@ -93,7 +93,7 @@ async def get_current_user_optional(
|
||||
|
||||
if not token:
|
||||
return None
|
||||
|
||||
|
||||
payload = AuthService.decode_token(token)
|
||||
|
||||
if payload is None:
|
||||
|
||||
@ -293,7 +293,7 @@ async def google_login(redirect: Optional[str] = Query(None)):
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Google OAuth not configured",
|
||||
)
|
||||
|
||||
|
||||
redirect_path = _sanitize_redirect_path(redirect)
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
state = _create_oauth_state("google", nonce, redirect_path)
|
||||
@ -394,7 +394,7 @@ async def google_callback(
|
||||
if is_new:
|
||||
query["new"] = "true"
|
||||
redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}"
|
||||
|
||||
|
||||
response = RedirectResponse(url=redirect_url)
|
||||
_clear_oauth_nonce_cookie(response, "google")
|
||||
set_auth_cookie(
|
||||
@ -421,7 +421,7 @@ async def github_login(redirect: Optional[str] = Query(None)):
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="GitHub OAuth not configured",
|
||||
)
|
||||
|
||||
|
||||
redirect_path = _sanitize_redirect_path(redirect)
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
state = _create_oauth_state("github", nonce, redirect_path)
|
||||
@ -551,7 +551,7 @@ async def github_callback(
|
||||
if is_new:
|
||||
query["new"] = "true"
|
||||
redirect_url = f"{FRONTEND_URL}/oauth/callback?{urlencode(query)}"
|
||||
|
||||
|
||||
response = RedirectResponse(url=redirect_url)
|
||||
_clear_oauth_nonce_cookie(response, "github")
|
||||
set_auth_cookie(
|
||||
|
||||
637
backend/app/api/yield_domains.py
Normal file
637
backend/app/api/yield_domains.py
Normal file
@ -0,0 +1,637 @@
|
||||
"""
|
||||
Yield Domain API endpoints.
|
||||
|
||||
Manages domain activation for yield/intent routing and revenue tracking.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import func, and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner
|
||||
from app.config import settings
|
||||
from app.schemas.yield_domain import (
|
||||
YieldDomainCreate,
|
||||
YieldDomainUpdate,
|
||||
YieldDomainResponse,
|
||||
YieldDomainListResponse,
|
||||
YieldTransactionResponse,
|
||||
YieldTransactionListResponse,
|
||||
YieldPayoutResponse,
|
||||
YieldPayoutListResponse,
|
||||
YieldDashboardStats,
|
||||
YieldDashboardResponse,
|
||||
DomainYieldAnalysis,
|
||||
IntentAnalysis,
|
||||
YieldValueEstimate,
|
||||
AffiliatePartnerResponse,
|
||||
DNSVerificationResult,
|
||||
DNSSetupInstructions,
|
||||
ActivateYieldRequest,
|
||||
ActivateYieldResponse,
|
||||
)
|
||||
from app.services.intent_detector import (
|
||||
detect_domain_intent,
|
||||
estimate_domain_yield,
|
||||
get_intent_detector,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/yield", tags=["yield"])
|
||||
|
||||
# DNS Configuration (would be in config in production)
|
||||
YIELD_NAMESERVERS = ["ns1.pounce.io", "ns2.pounce.io"]
|
||||
YIELD_CNAME_TARGET = "yield.pounce.io"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Intent Analysis (Public)
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/analyze", response_model=DomainYieldAnalysis)
|
||||
async def analyze_domain_intent(
|
||||
domain: str = Query(..., min_length=3, description="Domain to analyze"),
|
||||
):
|
||||
"""
|
||||
Analyze a domain's intent and estimate yield potential.
|
||||
|
||||
This endpoint is public - no authentication required.
|
||||
"""
|
||||
analysis = estimate_domain_yield(domain)
|
||||
|
||||
intent_result = detect_domain_intent(domain)
|
||||
|
||||
return DomainYieldAnalysis(
|
||||
domain=domain,
|
||||
intent=IntentAnalysis(
|
||||
category=intent_result.category,
|
||||
subcategory=intent_result.subcategory,
|
||||
confidence=intent_result.confidence,
|
||||
keywords_matched=intent_result.keywords_matched,
|
||||
suggested_partners=intent_result.suggested_partners,
|
||||
monetization_potential=intent_result.monetization_potential,
|
||||
),
|
||||
value=YieldValueEstimate(
|
||||
estimated_monthly_min=analysis["value"]["estimated_monthly_min"],
|
||||
estimated_monthly_max=analysis["value"]["estimated_monthly_max"],
|
||||
currency=analysis["value"]["currency"],
|
||||
potential=analysis["value"]["potential"],
|
||||
confidence=analysis["value"]["confidence"],
|
||||
geo=analysis["value"]["geo"],
|
||||
),
|
||||
partners=analysis["partners"],
|
||||
monetization_potential=analysis["monetization_potential"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dashboard
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/dashboard", response_model=YieldDashboardResponse)
|
||||
async def get_yield_dashboard(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get yield dashboard with stats, domains, and recent transactions.
|
||||
"""
|
||||
# Get user's yield domains
|
||||
domains = db.query(YieldDomain).filter(
|
||||
YieldDomain.user_id == current_user.id
|
||||
).order_by(YieldDomain.total_revenue.desc()).all()
|
||||
|
||||
# Calculate stats
|
||||
now = datetime.utcnow()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Monthly stats from transactions
|
||||
monthly_stats = db.query(
|
||||
func.count(YieldTransaction.id).label("count"),
|
||||
func.sum(YieldTransaction.net_amount).label("revenue"),
|
||||
func.sum(func.cast(YieldTransaction.event_type == "click", Integer)).label("clicks"),
|
||||
func.sum(func.cast(YieldTransaction.event_type.in_(["lead", "sale"]), Integer)).label("conversions"),
|
||||
).join(YieldDomain).filter(
|
||||
YieldDomain.user_id == current_user.id,
|
||||
YieldTransaction.created_at >= month_start,
|
||||
).first()
|
||||
|
||||
# Aggregate domain stats
|
||||
total_active = sum(1 for d in domains if d.status == "active")
|
||||
total_pending = sum(1 for d in domains if d.status in ["pending", "verifying"])
|
||||
lifetime_revenue = sum(d.total_revenue for d in domains)
|
||||
lifetime_clicks = sum(d.total_clicks for d in domains)
|
||||
lifetime_conversions = sum(d.total_conversions for d in domains)
|
||||
|
||||
# Pending payout
|
||||
pending_payout = db.query(func.sum(YieldTransaction.net_amount)).filter(
|
||||
YieldTransaction.yield_domain_id.in_([d.id for d in domains]),
|
||||
YieldTransaction.status == "confirmed",
|
||||
YieldTransaction.paid_at.is_(None),
|
||||
).scalar() or Decimal("0")
|
||||
|
||||
# Get recent transactions
|
||||
recent_txs = db.query(YieldTransaction).join(YieldDomain).filter(
|
||||
YieldDomain.user_id == current_user.id,
|
||||
).order_by(YieldTransaction.created_at.desc()).limit(10).all()
|
||||
|
||||
# Top performing domains
|
||||
top_domains = sorted(domains, key=lambda d: d.total_revenue, reverse=True)[:5]
|
||||
|
||||
stats = YieldDashboardStats(
|
||||
total_domains=len(domains),
|
||||
active_domains=total_active,
|
||||
pending_domains=total_pending,
|
||||
monthly_revenue=monthly_stats.revenue or Decimal("0"),
|
||||
monthly_clicks=monthly_stats.clicks or 0,
|
||||
monthly_conversions=monthly_stats.conversions or 0,
|
||||
lifetime_revenue=lifetime_revenue,
|
||||
lifetime_clicks=lifetime_clicks,
|
||||
lifetime_conversions=lifetime_conversions,
|
||||
pending_payout=pending_payout,
|
||||
next_payout_date=month_start + timedelta(days=32), # Approx next month
|
||||
currency="CHF",
|
||||
)
|
||||
|
||||
return YieldDashboardResponse(
|
||||
stats=stats,
|
||||
domains=[_domain_to_response(d) for d in domains],
|
||||
recent_transactions=[_tx_to_response(tx) for tx in recent_txs],
|
||||
top_domains=[_domain_to_response(d) for d in top_domains],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Domain Management
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/domains", response_model=YieldDomainListResponse)
|
||||
async def list_yield_domains(
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List user's yield domains.
|
||||
"""
|
||||
query = db.query(YieldDomain).filter(YieldDomain.user_id == current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.filter(YieldDomain.status == status)
|
||||
|
||||
total = query.count()
|
||||
domains = query.order_by(YieldDomain.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Aggregates
|
||||
all_domains = db.query(YieldDomain).filter(YieldDomain.user_id == current_user.id).all()
|
||||
total_active = sum(1 for d in all_domains if d.status == "active")
|
||||
total_revenue = sum(d.total_revenue for d in all_domains)
|
||||
total_clicks = sum(d.total_clicks for d in all_domains)
|
||||
|
||||
return YieldDomainListResponse(
|
||||
domains=[_domain_to_response(d) for d in domains],
|
||||
total=total,
|
||||
total_active=total_active,
|
||||
total_revenue=total_revenue,
|
||||
total_clicks=total_clicks,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/domains/{domain_id}", response_model=YieldDomainResponse)
|
||||
async def get_yield_domain(
|
||||
domain_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Get details of a specific yield domain.
|
||||
"""
|
||||
domain = db.query(YieldDomain).filter(
|
||||
YieldDomain.id == domain_id,
|
||||
YieldDomain.user_id == current_user.id,
|
||||
).first()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(status_code=404, detail="Yield domain not found")
|
||||
|
||||
return _domain_to_response(domain)
|
||||
|
||||
|
||||
@router.post("/activate", response_model=ActivateYieldResponse)
|
||||
async def activate_domain_for_yield(
|
||||
request: ActivateYieldRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Activate a domain for yield/intent routing.
|
||||
|
||||
This creates the yield domain record and returns DNS setup instructions.
|
||||
"""
|
||||
domain = request.domain.lower().strip()
|
||||
|
||||
# Check if domain already exists
|
||||
existing = db.query(YieldDomain).filter(YieldDomain.domain == domain).first()
|
||||
if existing:
|
||||
if existing.user_id == current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Domain already activated for yield"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Domain is already registered by another user"
|
||||
)
|
||||
|
||||
# Analyze domain intent
|
||||
intent_result = detect_domain_intent(domain)
|
||||
value_estimate = get_intent_detector().estimate_value(domain)
|
||||
|
||||
# Create yield domain record
|
||||
yield_domain = YieldDomain(
|
||||
user_id=current_user.id,
|
||||
domain=domain,
|
||||
detected_intent=f"{intent_result.category}_{intent_result.subcategory}" if intent_result.subcategory else intent_result.category,
|
||||
intent_confidence=intent_result.confidence,
|
||||
intent_keywords=json.dumps(intent_result.keywords_matched),
|
||||
status="pending",
|
||||
)
|
||||
|
||||
# Find best matching partner
|
||||
if intent_result.suggested_partners:
|
||||
partner = db.query(AffiliatePartner).filter(
|
||||
AffiliatePartner.slug == intent_result.suggested_partners[0],
|
||||
AffiliatePartner.is_active == True,
|
||||
).first()
|
||||
if partner:
|
||||
yield_domain.partner_id = partner.id
|
||||
yield_domain.active_route = partner.slug
|
||||
|
||||
db.add(yield_domain)
|
||||
db.commit()
|
||||
db.refresh(yield_domain)
|
||||
|
||||
# Create DNS instructions
|
||||
dns_instructions = DNSSetupInstructions(
|
||||
domain=domain,
|
||||
nameservers=YIELD_NAMESERVERS,
|
||||
cname_host="@",
|
||||
cname_target=YIELD_CNAME_TARGET,
|
||||
verification_url=f"{settings.site_url}/api/v1/yield/verify/{yield_domain.id}",
|
||||
)
|
||||
|
||||
return ActivateYieldResponse(
|
||||
domain_id=yield_domain.id,
|
||||
domain=domain,
|
||||
status=yield_domain.status,
|
||||
intent=IntentAnalysis(
|
||||
category=intent_result.category,
|
||||
subcategory=intent_result.subcategory,
|
||||
confidence=intent_result.confidence,
|
||||
keywords_matched=intent_result.keywords_matched,
|
||||
suggested_partners=intent_result.suggested_partners,
|
||||
monetization_potential=intent_result.monetization_potential,
|
||||
),
|
||||
value_estimate=YieldValueEstimate(
|
||||
estimated_monthly_min=value_estimate["estimated_monthly_min"],
|
||||
estimated_monthly_max=value_estimate["estimated_monthly_max"],
|
||||
currency=value_estimate["currency"],
|
||||
potential=value_estimate["potential"],
|
||||
confidence=value_estimate["confidence"],
|
||||
geo=value_estimate["geo"],
|
||||
),
|
||||
dns_instructions=dns_instructions,
|
||||
message="Domain registered! Point your DNS to our nameservers to complete activation.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/domains/{domain_id}/verify", response_model=DNSVerificationResult)
|
||||
async def verify_domain_dns(
|
||||
domain_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Verify DNS configuration for a yield domain.
|
||||
"""
|
||||
domain = db.query(YieldDomain).filter(
|
||||
YieldDomain.id == domain_id,
|
||||
YieldDomain.user_id == current_user.id,
|
||||
).first()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(status_code=404, detail="Yield domain not found")
|
||||
|
||||
# Perform DNS check (simplified - in production use dnspython)
|
||||
verified = False
|
||||
actual_ns = []
|
||||
error = None
|
||||
|
||||
try:
|
||||
import dns.resolver
|
||||
|
||||
# Check nameservers
|
||||
try:
|
||||
answers = dns.resolver.resolve(domain.domain, 'NS')
|
||||
actual_ns = [str(rr.target).rstrip('.') for rr in answers]
|
||||
|
||||
# Check if our nameservers are set
|
||||
our_ns_set = set(ns.lower() for ns in YIELD_NAMESERVERS)
|
||||
actual_ns_set = set(ns.lower() for ns in actual_ns)
|
||||
|
||||
if our_ns_set.issubset(actual_ns_set):
|
||||
verified = True
|
||||
except dns.resolver.NXDOMAIN:
|
||||
error = "Domain does not exist"
|
||||
except dns.resolver.NoAnswer:
|
||||
# Try CNAME instead
|
||||
try:
|
||||
cname_answers = dns.resolver.resolve(domain.domain, 'CNAME')
|
||||
for rr in cname_answers:
|
||||
if str(rr.target).rstrip('.').lower() == YIELD_CNAME_TARGET.lower():
|
||||
verified = True
|
||||
break
|
||||
except Exception:
|
||||
error = "No NS or CNAME records found"
|
||||
except Exception as e:
|
||||
error = str(e)
|
||||
|
||||
except ImportError:
|
||||
# dnspython not installed - simulate for development
|
||||
verified = True # Auto-verify in dev
|
||||
actual_ns = YIELD_NAMESERVERS
|
||||
|
||||
# Update domain status
|
||||
if verified and not domain.dns_verified:
|
||||
domain.dns_verified = True
|
||||
domain.dns_verified_at = datetime.utcnow()
|
||||
domain.status = "active"
|
||||
domain.activated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return DNSVerificationResult(
|
||||
domain=domain.domain,
|
||||
verified=verified,
|
||||
expected_ns=YIELD_NAMESERVERS,
|
||||
actual_ns=actual_ns,
|
||||
cname_ok=verified and not actual_ns,
|
||||
error=error,
|
||||
checked_at=datetime.utcnow(),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/domains/{domain_id}", response_model=YieldDomainResponse)
|
||||
async def update_yield_domain(
|
||||
domain_id: int,
|
||||
update: YieldDomainUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Update yield domain settings.
|
||||
"""
|
||||
domain = db.query(YieldDomain).filter(
|
||||
YieldDomain.id == domain_id,
|
||||
YieldDomain.user_id == current_user.id,
|
||||
).first()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(status_code=404, detail="Yield domain not found")
|
||||
|
||||
# Apply updates
|
||||
if update.active_route is not None:
|
||||
# Validate partner exists
|
||||
partner = db.query(AffiliatePartner).filter(
|
||||
AffiliatePartner.slug == update.active_route,
|
||||
AffiliatePartner.is_active == True,
|
||||
).first()
|
||||
if not partner:
|
||||
raise HTTPException(status_code=400, detail="Invalid partner route")
|
||||
domain.active_route = update.active_route
|
||||
domain.partner_id = partner.id
|
||||
|
||||
if update.landing_page_url is not None:
|
||||
domain.landing_page_url = update.landing_page_url
|
||||
|
||||
if update.status is not None:
|
||||
if update.status == "paused":
|
||||
domain.status = "paused"
|
||||
domain.paused_at = datetime.utcnow()
|
||||
elif update.status == "active" and domain.dns_verified:
|
||||
domain.status = "active"
|
||||
domain.paused_at = None
|
||||
|
||||
db.commit()
|
||||
db.refresh(domain)
|
||||
|
||||
return _domain_to_response(domain)
|
||||
|
||||
|
||||
@router.delete("/domains/{domain_id}")
|
||||
async def delete_yield_domain(
|
||||
domain_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
Remove a domain from yield program.
|
||||
"""
|
||||
domain = db.query(YieldDomain).filter(
|
||||
YieldDomain.id == domain_id,
|
||||
YieldDomain.user_id == current_user.id,
|
||||
).first()
|
||||
|
||||
if not domain:
|
||||
raise HTTPException(status_code=404, detail="Yield domain not found")
|
||||
|
||||
db.delete(domain)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Yield domain removed"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Transactions
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/transactions", response_model=YieldTransactionListResponse)
|
||||
async def list_transactions(
|
||||
domain_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
limit: int = Query(50, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List yield transactions for user's domains.
|
||||
"""
|
||||
# Get user's domain IDs
|
||||
domain_ids = db.query(YieldDomain.id).filter(
|
||||
YieldDomain.user_id == current_user.id
|
||||
).subquery()
|
||||
|
||||
query = db.query(YieldTransaction).filter(
|
||||
YieldTransaction.yield_domain_id.in_(domain_ids)
|
||||
)
|
||||
|
||||
if domain_id:
|
||||
query = query.filter(YieldTransaction.yield_domain_id == domain_id)
|
||||
|
||||
if status:
|
||||
query = query.filter(YieldTransaction.status == status)
|
||||
|
||||
total = query.count()
|
||||
transactions = query.order_by(YieldTransaction.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Aggregates
|
||||
total_gross = sum(tx.gross_amount for tx in transactions)
|
||||
total_net = sum(tx.net_amount for tx in transactions)
|
||||
|
||||
return YieldTransactionListResponse(
|
||||
transactions=[_tx_to_response(tx) for tx in transactions],
|
||||
total=total,
|
||||
total_gross=total_gross,
|
||||
total_net=total_net,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Payouts
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/payouts", response_model=YieldPayoutListResponse)
|
||||
async def list_payouts(
|
||||
status: Optional[str] = Query(None),
|
||||
limit: int = Query(20, le=50),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List user's yield payouts.
|
||||
"""
|
||||
query = db.query(YieldPayout).filter(YieldPayout.user_id == current_user.id)
|
||||
|
||||
if status:
|
||||
query = query.filter(YieldPayout.status == status)
|
||||
|
||||
total = query.count()
|
||||
payouts = query.order_by(YieldPayout.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
# Aggregates
|
||||
total_paid = sum(p.amount for p in payouts if p.status == "completed")
|
||||
total_pending = sum(p.amount for p in payouts if p.status in ["pending", "processing"])
|
||||
|
||||
return YieldPayoutListResponse(
|
||||
payouts=[_payout_to_response(p) for p in payouts],
|
||||
total=total,
|
||||
total_paid=total_paid,
|
||||
total_pending=total_pending,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Partners (Public info)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/partners", response_model=list[AffiliatePartnerResponse])
|
||||
async def list_partners(
|
||||
category: Optional[str] = Query(None, description="Filter by intent category"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
List available affiliate partners.
|
||||
"""
|
||||
query = db.query(AffiliatePartner).filter(AffiliatePartner.is_active == True)
|
||||
|
||||
partners = query.order_by(AffiliatePartner.priority.desc()).all()
|
||||
|
||||
# Filter by category if specified
|
||||
if category:
|
||||
partners = [p for p in partners if category in p.intent_list]
|
||||
|
||||
return [
|
||||
AffiliatePartnerResponse(
|
||||
slug=p.slug,
|
||||
name=p.name,
|
||||
network=p.network,
|
||||
intent_categories=p.intent_list,
|
||||
geo_countries=p.country_list,
|
||||
payout_type=p.payout_type,
|
||||
description=p.description,
|
||||
logo_url=p.logo_url,
|
||||
)
|
||||
for p in partners
|
||||
]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helpers
|
||||
# ============================================================================
|
||||
|
||||
def _domain_to_response(domain: YieldDomain) -> YieldDomainResponse:
|
||||
"""Convert YieldDomain model to response schema."""
|
||||
return YieldDomainResponse(
|
||||
id=domain.id,
|
||||
domain=domain.domain,
|
||||
status=domain.status,
|
||||
detected_intent=domain.detected_intent,
|
||||
intent_confidence=domain.intent_confidence,
|
||||
active_route=domain.active_route,
|
||||
partner_name=domain.partner.name if domain.partner else None,
|
||||
dns_verified=domain.dns_verified,
|
||||
dns_verified_at=domain.dns_verified_at,
|
||||
total_clicks=domain.total_clicks,
|
||||
total_conversions=domain.total_conversions,
|
||||
total_revenue=domain.total_revenue,
|
||||
currency=domain.currency,
|
||||
activated_at=domain.activated_at,
|
||||
created_at=domain.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _tx_to_response(tx: YieldTransaction) -> YieldTransactionResponse:
|
||||
"""Convert YieldTransaction model to response schema."""
|
||||
return YieldTransactionResponse(
|
||||
id=tx.id,
|
||||
event_type=tx.event_type,
|
||||
partner_slug=tx.partner_slug,
|
||||
gross_amount=tx.gross_amount,
|
||||
net_amount=tx.net_amount,
|
||||
currency=tx.currency,
|
||||
status=tx.status,
|
||||
geo_country=tx.geo_country,
|
||||
created_at=tx.created_at,
|
||||
confirmed_at=tx.confirmed_at,
|
||||
)
|
||||
|
||||
|
||||
def _payout_to_response(payout: YieldPayout) -> YieldPayoutResponse:
|
||||
"""Convert YieldPayout model to response schema."""
|
||||
return YieldPayoutResponse(
|
||||
id=payout.id,
|
||||
amount=payout.amount,
|
||||
currency=payout.currency,
|
||||
period_start=payout.period_start,
|
||||
period_end=payout.period_end,
|
||||
transaction_count=payout.transaction_count,
|
||||
status=payout.status,
|
||||
payment_method=payout.payment_method,
|
||||
payment_reference=payout.payment_reference,
|
||||
created_at=payout.created_at,
|
||||
completed_at=payout.completed_at,
|
||||
)
|
||||
|
||||
|
||||
# Missing import
|
||||
from sqlalchemy import Integer
|
||||
|
||||
@ -49,8 +49,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Start scheduler (optional - recommended: run in separate process/container)
|
||||
if settings.enable_scheduler:
|
||||
start_scheduler()
|
||||
logger.info("Scheduler started")
|
||||
start_scheduler()
|
||||
logger.info("Scheduler started")
|
||||
else:
|
||||
logger.info("Scheduler disabled (ENABLE_SCHEDULER=false)")
|
||||
|
||||
@ -58,7 +58,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
if settings.enable_scheduler:
|
||||
stop_scheduler()
|
||||
stop_scheduler()
|
||||
logger.info("Application shutdown complete")
|
||||
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from app.models.blog import BlogPost
|
||||
from app.models.listing import DomainListing, ListingInquiry, ListingView
|
||||
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||
from app.models.seo_data import DomainSEOData
|
||||
from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -37,4 +38,9 @@ __all__ = [
|
||||
"SniperAlertMatch",
|
||||
# New: SEO Data (Tycoon feature)
|
||||
"DomainSEOData",
|
||||
# New: Yield / Intent Routing
|
||||
"YieldDomain",
|
||||
"YieldTransaction",
|
||||
"YieldPayout",
|
||||
"AffiliatePartner",
|
||||
]
|
||||
|
||||
@ -68,6 +68,13 @@ class User(Base):
|
||||
sniper_alerts: Mapped[List["SniperAlert"]] = relationship(
|
||||
"SniperAlert", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
# Yield Domains
|
||||
yield_domains: Mapped[List["YieldDomain"]] = relationship(
|
||||
"YieldDomain", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
yield_payouts: Mapped[List["YieldPayout"]] = relationship(
|
||||
"YieldPayout", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.email}>"
|
||||
|
||||
249
backend/app/models/yield_domain.py
Normal file
249
backend/app/models/yield_domain.py
Normal file
@ -0,0 +1,249 @@
|
||||
"""
|
||||
Yield Domain models for Intent Routing feature.
|
||||
|
||||
Domains activated for yield generate passive income by routing
|
||||
visitor intent to affiliate partners.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, Numeric, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AffiliatePartner(Base):
|
||||
"""
|
||||
Affiliate network/partner configuration.
|
||||
|
||||
Partners are matched to domains based on detected intent category.
|
||||
"""
|
||||
__tablename__ = "affiliate_partners"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
|
||||
# Identity
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False) # "Comparis Dental"
|
||||
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) # "comparis_dental"
|
||||
network: Mapped[str] = mapped_column(String(50), nullable=False) # "awin", "partnerstack", "direct"
|
||||
|
||||
# Matching criteria (JSON arrays stored as comma-separated for simplicity)
|
||||
intent_categories: Mapped[str] = mapped_column(Text, nullable=False) # "medical_dental,medical_general"
|
||||
geo_countries: Mapped[str] = mapped_column(String(200), default="CH,DE,AT") # ISO codes
|
||||
|
||||
# Payout configuration
|
||||
payout_type: Mapped[str] = mapped_column(String(20), default="cpl") # "cpc", "cpl", "cps"
|
||||
payout_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0)
|
||||
payout_currency: Mapped[str] = mapped_column(String(3), default="CHF")
|
||||
|
||||
# Integration
|
||||
tracking_url_template: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
api_endpoint: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
# Note: API keys should be stored encrypted or in env vars, not here
|
||||
|
||||
# Display
|
||||
logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Status
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=0) # Higher = preferred
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
yield_domains: Mapped[list["YieldDomain"]] = relationship("YieldDomain", back_populates="partner")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AffiliatePartner {self.slug}>"
|
||||
|
||||
@property
|
||||
def intent_list(self) -> list[str]:
|
||||
"""Parse intent categories as list."""
|
||||
return [c.strip() for c in self.intent_categories.split(",") if c.strip()]
|
||||
|
||||
@property
|
||||
def country_list(self) -> list[str]:
|
||||
"""Parse geo countries as list."""
|
||||
return [c.strip() for c in self.geo_countries.split(",") if c.strip()]
|
||||
|
||||
|
||||
class YieldDomain(Base):
|
||||
"""
|
||||
Domain activated for yield/intent routing.
|
||||
|
||||
When a user activates a domain for yield:
|
||||
1. They point DNS to our nameservers
|
||||
2. We detect the intent (e.g., "zahnarzt.ch" → medical/dental)
|
||||
3. We route traffic to affiliate partners
|
||||
4. User earns commission split
|
||||
"""
|
||||
__tablename__ = "yield_domains"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
|
||||
# Domain info
|
||||
domain: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
|
||||
# Intent detection
|
||||
detected_intent: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # "medical_dental"
|
||||
intent_confidence: Mapped[float] = mapped_column(Float, default=0.0) # 0.0 - 1.0
|
||||
intent_keywords: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON: ["zahnarzt", "zuerich"]
|
||||
|
||||
# Routing
|
||||
partner_id: Mapped[Optional[int]] = mapped_column(ForeignKey("affiliate_partners.id"), nullable=True)
|
||||
active_route: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) # Partner slug
|
||||
landing_page_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(30), default="pending", index=True)
|
||||
# pending, verifying, active, paused, inactive, error
|
||||
|
||||
dns_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
dns_verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
activated_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Revenue tracking (aggregates, updated periodically)
|
||||
total_clicks: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_conversions: Mapped[int] = mapped_column(Integer, default=0)
|
||||
total_revenue: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="CHF")
|
||||
|
||||
# Last activity
|
||||
last_click_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
last_conversion_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user: Mapped["User"] = relationship("User", back_populates="yield_domains")
|
||||
partner: Mapped[Optional["AffiliatePartner"]] = relationship("AffiliatePartner", back_populates="yield_domains")
|
||||
transactions: Mapped[list["YieldTransaction"]] = relationship(
|
||||
"YieldTransaction", back_populates="yield_domain", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_yield_domains_user_status", "user_id", "status"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<YieldDomain {self.domain} ({self.status})>"
|
||||
|
||||
@property
|
||||
def is_earning(self) -> bool:
|
||||
"""Check if domain is actively earning."""
|
||||
return self.status == "active" and self.dns_verified
|
||||
|
||||
@property
|
||||
def monthly_revenue(self) -> Decimal:
|
||||
"""Estimate monthly revenue (placeholder - should compute from transactions)."""
|
||||
# In production: calculate from last 30 days of transactions
|
||||
return self.total_revenue
|
||||
|
||||
|
||||
class YieldTransaction(Base):
|
||||
"""
|
||||
Revenue events from affiliate partners.
|
||||
|
||||
Tracks clicks, leads, and sales for each yield domain.
|
||||
"""
|
||||
__tablename__ = "yield_transactions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
yield_domain_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("yield_domains.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Event type
|
||||
event_type: Mapped[str] = mapped_column(String(20), nullable=False) # "click", "lead", "sale"
|
||||
|
||||
# Partner info
|
||||
partner_slug: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
partner_transaction_id: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
|
||||
# Amount
|
||||
gross_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0) # Full commission
|
||||
net_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=0) # After Pounce cut (70%)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="CHF")
|
||||
|
||||
# Attribution
|
||||
referrer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
geo_country: Mapped[Optional[str]] = mapped_column(String(2), nullable=True)
|
||||
ip_hash: Mapped[Optional[str]] = mapped_column(String(64), nullable=True) # Hashed for privacy
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
# pending, confirmed, paid, rejected
|
||||
|
||||
confirmed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
paid_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
payout_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # FK to future payouts table
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
# Relationships
|
||||
yield_domain: Mapped["YieldDomain"] = relationship("YieldDomain", back_populates="transactions")
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_yield_tx_domain_created", "yield_domain_id", "created_at"),
|
||||
Index("ix_yield_tx_status_created", "status", "created_at"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<YieldTransaction {self.event_type} {self.net_amount} {self.currency}>"
|
||||
|
||||
|
||||
class YieldPayout(Base):
|
||||
"""
|
||||
Payout records for user earnings.
|
||||
|
||||
Aggregates confirmed transactions into periodic payouts.
|
||||
"""
|
||||
__tablename__ = "yield_payouts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||
|
||||
# Amount
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), default="CHF")
|
||||
|
||||
# Period
|
||||
period_start: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
period_end: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
|
||||
# Transaction count
|
||||
transaction_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
# Status
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending", index=True)
|
||||
# pending, processing, completed, failed
|
||||
|
||||
# Payment details
|
||||
payment_method: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # "stripe", "bank"
|
||||
payment_reference: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
|
||||
# Timestamps
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
processed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user: Mapped["User"] = relationship("User", back_populates="yield_payouts")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<YieldPayout {self.amount} {self.currency} ({self.status})>"
|
||||
|
||||
284
backend/app/schemas/yield_domain.py
Normal file
284
backend/app/schemas/yield_domain.py
Normal file
@ -0,0 +1,284 @@
|
||||
"""
|
||||
Pydantic schemas for Yield/Intent Routing feature.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Intent Detection
|
||||
# ============================================================================
|
||||
|
||||
class IntentAnalysis(BaseModel):
|
||||
"""Intent detection result for a domain."""
|
||||
category: str
|
||||
subcategory: Optional[str] = None
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
keywords_matched: list[str] = []
|
||||
suggested_partners: list[str] = []
|
||||
monetization_potential: str # "high", "medium", "low"
|
||||
|
||||
|
||||
class YieldValueEstimate(BaseModel):
|
||||
"""Estimated yield value for a domain."""
|
||||
estimated_monthly_min: int
|
||||
estimated_monthly_max: int
|
||||
currency: str = "CHF"
|
||||
potential: str
|
||||
confidence: float
|
||||
geo: Optional[str] = None
|
||||
|
||||
|
||||
class DomainYieldAnalysis(BaseModel):
|
||||
"""Complete yield analysis for a domain."""
|
||||
domain: str
|
||||
intent: IntentAnalysis
|
||||
value: YieldValueEstimate
|
||||
partners: list[str] = []
|
||||
monetization_potential: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Yield Domain CRUD
|
||||
# ============================================================================
|
||||
|
||||
class YieldDomainCreate(BaseModel):
|
||||
"""Create a new yield domain."""
|
||||
domain: str = Field(..., min_length=3, max_length=255)
|
||||
|
||||
|
||||
class YieldDomainUpdate(BaseModel):
|
||||
"""Update yield domain settings."""
|
||||
active_route: Optional[str] = None
|
||||
landing_page_url: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class YieldDomainResponse(BaseModel):
|
||||
"""Yield domain response."""
|
||||
id: int
|
||||
domain: str
|
||||
status: str
|
||||
|
||||
# Intent
|
||||
detected_intent: Optional[str] = None
|
||||
intent_confidence: float = 0.0
|
||||
|
||||
# Routing
|
||||
active_route: Optional[str] = None
|
||||
partner_name: Optional[str] = None
|
||||
|
||||
# DNS
|
||||
dns_verified: bool = False
|
||||
dns_verified_at: Optional[datetime] = None
|
||||
|
||||
# Stats
|
||||
total_clicks: int = 0
|
||||
total_conversions: int = 0
|
||||
total_revenue: Decimal = Decimal("0")
|
||||
currency: str = "CHF"
|
||||
|
||||
# Timestamps
|
||||
activated_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class YieldDomainListResponse(BaseModel):
|
||||
"""List of yield domains with summary stats."""
|
||||
domains: list[YieldDomainResponse]
|
||||
total: int
|
||||
|
||||
# Aggregates
|
||||
total_active: int = 0
|
||||
total_revenue: Decimal = Decimal("0")
|
||||
total_clicks: int = 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Transactions
|
||||
# ============================================================================
|
||||
|
||||
class YieldTransactionResponse(BaseModel):
|
||||
"""Single transaction record."""
|
||||
id: int
|
||||
event_type: str
|
||||
partner_slug: str
|
||||
|
||||
gross_amount: Decimal
|
||||
net_amount: Decimal
|
||||
currency: str
|
||||
|
||||
status: str
|
||||
geo_country: Optional[str] = None
|
||||
|
||||
created_at: datetime
|
||||
confirmed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class YieldTransactionListResponse(BaseModel):
|
||||
"""List of transactions."""
|
||||
transactions: list[YieldTransactionResponse]
|
||||
total: int
|
||||
|
||||
# Aggregates
|
||||
total_gross: Decimal = Decimal("0")
|
||||
total_net: Decimal = Decimal("0")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Payouts
|
||||
# ============================================================================
|
||||
|
||||
class YieldPayoutResponse(BaseModel):
|
||||
"""Payout record."""
|
||||
id: int
|
||||
amount: Decimal
|
||||
currency: str
|
||||
|
||||
period_start: datetime
|
||||
period_end: datetime
|
||||
|
||||
transaction_count: int
|
||||
status: str
|
||||
|
||||
payment_method: Optional[str] = None
|
||||
payment_reference: Optional[str] = None
|
||||
|
||||
created_at: datetime
|
||||
completed_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class YieldPayoutListResponse(BaseModel):
|
||||
"""List of payouts."""
|
||||
payouts: list[YieldPayoutResponse]
|
||||
total: int
|
||||
total_paid: Decimal = Decimal("0")
|
||||
total_pending: Decimal = Decimal("0")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dashboard
|
||||
# ============================================================================
|
||||
|
||||
class YieldDashboardStats(BaseModel):
|
||||
"""Yield dashboard statistics."""
|
||||
# Domain counts
|
||||
total_domains: int = 0
|
||||
active_domains: int = 0
|
||||
pending_domains: int = 0
|
||||
|
||||
# Revenue (current month)
|
||||
monthly_revenue: Decimal = Decimal("0")
|
||||
monthly_clicks: int = 0
|
||||
monthly_conversions: int = 0
|
||||
|
||||
# Lifetime
|
||||
lifetime_revenue: Decimal = Decimal("0")
|
||||
lifetime_clicks: int = 0
|
||||
lifetime_conversions: int = 0
|
||||
|
||||
# Pending payout
|
||||
pending_payout: Decimal = Decimal("0")
|
||||
next_payout_date: Optional[datetime] = None
|
||||
|
||||
currency: str = "CHF"
|
||||
|
||||
|
||||
class YieldDashboardResponse(BaseModel):
|
||||
"""Complete yield dashboard data."""
|
||||
stats: YieldDashboardStats
|
||||
domains: list[YieldDomainResponse]
|
||||
recent_transactions: list[YieldTransactionResponse]
|
||||
top_domains: list[YieldDomainResponse]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Partners
|
||||
# ============================================================================
|
||||
|
||||
class AffiliatePartnerResponse(BaseModel):
|
||||
"""Affiliate partner info (public view)."""
|
||||
slug: str
|
||||
name: str
|
||||
network: str
|
||||
|
||||
intent_categories: list[str]
|
||||
geo_countries: list[str]
|
||||
|
||||
payout_type: str
|
||||
description: Optional[str] = None
|
||||
logo_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DNS Verification
|
||||
# ============================================================================
|
||||
|
||||
class DNSVerificationResult(BaseModel):
|
||||
"""Result of DNS verification check."""
|
||||
domain: str
|
||||
verified: bool
|
||||
|
||||
expected_ns: list[str]
|
||||
actual_ns: list[str]
|
||||
|
||||
cname_ok: bool = False
|
||||
|
||||
error: Optional[str] = None
|
||||
checked_at: datetime
|
||||
|
||||
|
||||
class DNSSetupInstructions(BaseModel):
|
||||
"""DNS setup instructions for a domain."""
|
||||
domain: str
|
||||
|
||||
# Option 1: Nameserver delegation
|
||||
nameservers: list[str]
|
||||
|
||||
# Option 2: CNAME
|
||||
cname_host: str
|
||||
cname_target: str
|
||||
|
||||
# Verification
|
||||
verification_url: str
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Activation Flow
|
||||
# ============================================================================
|
||||
|
||||
class ActivateYieldRequest(BaseModel):
|
||||
"""Request to activate a domain for yield."""
|
||||
domain: str = Field(..., min_length=3, max_length=255)
|
||||
accept_terms: bool = False
|
||||
|
||||
|
||||
class ActivateYieldResponse(BaseModel):
|
||||
"""Response after initiating yield activation."""
|
||||
domain_id: int
|
||||
domain: str
|
||||
status: str
|
||||
|
||||
# Analysis
|
||||
intent: IntentAnalysis
|
||||
value_estimate: YieldValueEstimate
|
||||
|
||||
# Setup
|
||||
dns_instructions: DNSSetupInstructions
|
||||
|
||||
message: str
|
||||
|
||||
497
backend/app/services/intent_detector.py
Normal file
497
backend/app/services/intent_detector.py
Normal file
@ -0,0 +1,497 @@
|
||||
"""
|
||||
Intent Detection Engine for Yield Domains.
|
||||
|
||||
Analyzes domain names to detect user intent and match with affiliate partners.
|
||||
Uses keyword matching, pattern detection, and NLP-lite techniques.
|
||||
"""
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntentResult:
|
||||
"""Result of intent detection for a domain."""
|
||||
category: str # Primary intent category
|
||||
subcategory: Optional[str] # More specific subcategory
|
||||
confidence: float # 0.0 - 1.0
|
||||
keywords_matched: list[str] # Which keywords triggered the match
|
||||
suggested_partners: list[str] # Affiliate partner slugs
|
||||
monetization_potential: str # "high", "medium", "low"
|
||||
|
||||
|
||||
# Intent categories with keywords (Swiss/German/English focus)
|
||||
INTENT_PATTERNS = {
|
||||
# Medical / Health
|
||||
"medical_dental": {
|
||||
"keywords": [
|
||||
"zahnarzt", "dental", "dentist", "zahn", "zähne", "kieferorthopäde",
|
||||
"implantate", "zahnklinik", "prothese", "bleaching", "zahnpflege",
|
||||
"dentalhygiene", "mundgesundheit", "braces", "orthodontist"
|
||||
],
|
||||
"patterns": [r"zahn\w*", r"dent\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["comparis_dental", "swisssmile", "dentaldeal"]
|
||||
},
|
||||
"medical_general": {
|
||||
"keywords": [
|
||||
"arzt", "doctor", "klinik", "clinic", "hospital", "spital",
|
||||
"praxis", "gesundheit", "health", "medizin", "medicine",
|
||||
"therapie", "therapy", "behandlung", "treatment"
|
||||
],
|
||||
"patterns": [r"med\w+", r"gesund\w*", r"health\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["comparis_health", "sanitas", "helsana"]
|
||||
},
|
||||
"medical_beauty": {
|
||||
"keywords": [
|
||||
"schönheit", "beauty", "kosmetik", "cosmetic", "botox",
|
||||
"filler", "laser", "aesthetic", "ästhetik", "haut", "skin",
|
||||
"anti-aging", "wellness", "spa", "massage"
|
||||
],
|
||||
"patterns": [r"beauty\w*", r"kosm\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["swissesthetic", "beautyfinder"]
|
||||
},
|
||||
|
||||
# Finance / Insurance
|
||||
"finance_insurance": {
|
||||
"keywords": [
|
||||
"versicherung", "insurance", "krankenkasse", "autoversicherung",
|
||||
"hausrat", "haftpflicht", "lebensversicherung", "police"
|
||||
],
|
||||
"patterns": [r"versicher\w*", r"insur\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["comparis_insurance", "bonus_ch", "financescout"]
|
||||
},
|
||||
"finance_mortgage": {
|
||||
"keywords": [
|
||||
"hypothek", "mortgage", "kredit", "credit", "darlehen", "loan",
|
||||
"finanzierung", "financing", "immobilien", "eigenheim"
|
||||
],
|
||||
"patterns": [r"hypo\w*", r"kredit\w*", r"mortg\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["comparis_hypo", "moneypark", "hypocenter"]
|
||||
},
|
||||
"finance_banking": {
|
||||
"keywords": [
|
||||
"bank", "banking", "konto", "account", "sparen", "savings",
|
||||
"anlegen", "invest", "geld", "money", "zinsen", "interest"
|
||||
],
|
||||
"patterns": [r"bank\w*", r"finanz\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["neon_bank", "yuh_ch"]
|
||||
},
|
||||
|
||||
# Legal
|
||||
"legal_general": {
|
||||
"keywords": [
|
||||
"anwalt", "lawyer", "rechtsanwalt", "attorney", "rechtshilfe",
|
||||
"legal", "recht", "law", "kanzlei", "advokat", "jurist"
|
||||
],
|
||||
"patterns": [r"anwalt\w*", r"recht\w*", r"law\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["legal_ch", "anwalt24"]
|
||||
},
|
||||
|
||||
# Real Estate
|
||||
"realestate_buy": {
|
||||
"keywords": [
|
||||
"immobilien", "realestate", "wohnung", "apartment", "haus", "house",
|
||||
"kaufen", "buy", "villa", "eigentum", "property", "liegenschaft"
|
||||
],
|
||||
"patterns": [r"immobil\w*", r"wohn\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["homegate", "immoscout", "comparis_immo"]
|
||||
},
|
||||
"realestate_rent": {
|
||||
"keywords": [
|
||||
"mieten", "rent", "miete", "mietwohnung", "rental", "wg",
|
||||
"studio", "loft", "untermiete"
|
||||
],
|
||||
"patterns": [r"miet\w*", r"rent\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["homegate", "flatfox"]
|
||||
},
|
||||
|
||||
# Travel
|
||||
"travel_flights": {
|
||||
"keywords": [
|
||||
"flug", "flight", "fliegen", "fly", "airline", "flughafen",
|
||||
"airport", "billigflug", "cheapflight", "reise", "travel"
|
||||
],
|
||||
"patterns": [r"fl[uy]g\w*", r"travel\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["skyscanner", "kayak", "booking"]
|
||||
},
|
||||
"travel_hotels": {
|
||||
"keywords": [
|
||||
"hotel", "unterkunft", "accommodation", "hostel", "pension",
|
||||
"resort", "übernachtung", "booking", "airbnb"
|
||||
],
|
||||
"patterns": [r"hotel\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["booking_com", "trivago", "hotels_com"]
|
||||
},
|
||||
|
||||
# E-Commerce / Shopping
|
||||
"shopping_general": {
|
||||
"keywords": [
|
||||
"shop", "store", "kaufen", "buy", "einkaufen", "shopping",
|
||||
"deals", "rabatt", "discount", "sale", "angebot", "offer"
|
||||
],
|
||||
"patterns": [r"shop\w*", r"deal\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["amazon_ch", "galaxus", "digitec"]
|
||||
},
|
||||
"shopping_fashion": {
|
||||
"keywords": [
|
||||
"mode", "fashion", "kleider", "clothes", "schuhe", "shoes",
|
||||
"outfit", "style", "bekleidung", "garderobe"
|
||||
],
|
||||
"patterns": [r"mode\w*", r"fash\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["zalando", "about_you"]
|
||||
},
|
||||
|
||||
# Automotive
|
||||
"auto_buy": {
|
||||
"keywords": [
|
||||
"auto", "car", "fahrzeug", "vehicle", "wagen", "neuwagen",
|
||||
"gebrauchtwagen", "occasion", "carmarket", "autohaus"
|
||||
],
|
||||
"patterns": [r"auto\w*", r"car\w*"],
|
||||
"potential": "high",
|
||||
"partners": ["autoscout", "comparis_auto", "carforyou"]
|
||||
},
|
||||
"auto_service": {
|
||||
"keywords": [
|
||||
"garage", "werkstatt", "reparatur", "repair", "service",
|
||||
"reifenwechsel", "inspektion", "tuning"
|
||||
],
|
||||
"patterns": [r"garag\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["autobutler"]
|
||||
},
|
||||
|
||||
# Jobs / Career
|
||||
"jobs": {
|
||||
"keywords": [
|
||||
"job", "jobs", "karriere", "career", "arbeit", "work",
|
||||
"stelle", "stellenangebot", "vacancy", "hiring", "bewerbung"
|
||||
],
|
||||
"patterns": [r"job\w*", r"karrier\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["jobs_ch", "indeed", "linkedin"]
|
||||
},
|
||||
|
||||
# Education
|
||||
"education": {
|
||||
"keywords": [
|
||||
"schule", "school", "uni", "university", "bildung", "education",
|
||||
"kurs", "course", "lernen", "learn", "ausbildung", "training",
|
||||
"weiterbildung", "studium", "studieren"
|
||||
],
|
||||
"patterns": [r"schul\w*", r"edu\w*", r"learn\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["udemy", "coursera", "edx"]
|
||||
},
|
||||
|
||||
# Technology
|
||||
"tech_hosting": {
|
||||
"keywords": [
|
||||
"hosting", "server", "cloud", "domain", "website", "webhosting",
|
||||
"vps", "dedicated", "webspace"
|
||||
],
|
||||
"patterns": [r"host\w*", r"server\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["hostpoint", "infomaniak", "cyon"]
|
||||
},
|
||||
"tech_software": {
|
||||
"keywords": [
|
||||
"software", "app", "tool", "saas", "crm", "erp",
|
||||
"programm", "application", "platform"
|
||||
],
|
||||
"patterns": [r"soft\w*", r"app\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["capterra", "g2"]
|
||||
},
|
||||
|
||||
# Food / Restaurant
|
||||
"food_restaurant": {
|
||||
"keywords": [
|
||||
"restaurant", "essen", "food", "pizza", "sushi", "burger",
|
||||
"cafe", "bistro", "gastronomie", "dining"
|
||||
],
|
||||
"patterns": [r"food\w*", r"pizza\w*"],
|
||||
"potential": "low",
|
||||
"partners": ["eatme", "uber_eats"]
|
||||
},
|
||||
"food_delivery": {
|
||||
"keywords": [
|
||||
"lieferung", "delivery", "liefern", "bestellen", "order",
|
||||
"takeaway", "takeout"
|
||||
],
|
||||
"patterns": [r"deliver\w*", r"liefer\w*"],
|
||||
"potential": "medium",
|
||||
"partners": ["uber_eats", "just_eat"]
|
||||
},
|
||||
}
|
||||
|
||||
# Swiss city names for geo-targeting
|
||||
SWISS_CITIES = {
|
||||
"zürich", "zurich", "zuerich", "zri", "zh",
|
||||
"bern", "genf", "geneva", "geneve",
|
||||
"basel", "lausanne", "luzern", "lucerne",
|
||||
"winterthur", "stgallen", "st-gallen", "lugano",
|
||||
"biel", "bienne", "thun", "köniz", "chur",
|
||||
"schaffhausen", "fribourg", "freiburg",
|
||||
"neuchatel", "neuenburg", "uster", "sion", "sitten",
|
||||
"zug", "aarau", "baden", "wil", "davos", "interlaken"
|
||||
}
|
||||
|
||||
# German cities
|
||||
GERMAN_CITIES = {
|
||||
"berlin", "münchen", "munich", "muenchen", "hamburg",
|
||||
"frankfurt", "köln", "koeln", "düsseldorf", "duesseldorf",
|
||||
"stuttgart", "dortmund", "essen", "leipzig", "bremen"
|
||||
}
|
||||
|
||||
|
||||
class IntentDetector:
|
||||
"""
|
||||
Detects user intent from domain names.
|
||||
|
||||
Uses keyword matching and pattern detection to categorize domains
|
||||
and suggest appropriate affiliate partners for monetization.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.patterns = INTENT_PATTERNS
|
||||
self.swiss_cities = SWISS_CITIES
|
||||
self.german_cities = GERMAN_CITIES
|
||||
|
||||
def detect(self, domain: str) -> IntentResult:
|
||||
"""
|
||||
Analyze a domain name and detect its intent category.
|
||||
|
||||
Args:
|
||||
domain: The domain name (e.g., "zahnarzt-zuerich.ch")
|
||||
|
||||
Returns:
|
||||
IntentResult with category, confidence, and partner suggestions
|
||||
"""
|
||||
# Normalize domain
|
||||
domain_clean = self._normalize_domain(domain)
|
||||
parts = self._split_domain_parts(domain_clean)
|
||||
|
||||
# Find best matching category
|
||||
best_match = None
|
||||
best_score = 0.0
|
||||
best_keywords = []
|
||||
|
||||
for category, config in self.patterns.items():
|
||||
score, matched_keywords = self._score_category(parts, config)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = category
|
||||
best_keywords = matched_keywords
|
||||
|
||||
# Determine confidence level
|
||||
confidence = min(best_score / 3.0, 1.0) # Normalize to 0-1
|
||||
|
||||
# If no strong match, return generic
|
||||
if best_score < 0.5 or best_match is None:
|
||||
return IntentResult(
|
||||
category="generic",
|
||||
subcategory=None,
|
||||
confidence=0.2,
|
||||
keywords_matched=[],
|
||||
suggested_partners=["generic_affiliate"],
|
||||
monetization_potential="low"
|
||||
)
|
||||
|
||||
# Get category config
|
||||
config = self.patterns[best_match]
|
||||
|
||||
# Split category into main and sub
|
||||
parts = best_match.split("_", 1)
|
||||
main_category = parts[0]
|
||||
subcategory = parts[1] if len(parts) > 1 else None
|
||||
|
||||
return IntentResult(
|
||||
category=main_category,
|
||||
subcategory=subcategory,
|
||||
confidence=confidence,
|
||||
keywords_matched=best_keywords,
|
||||
suggested_partners=config.get("partners", []),
|
||||
monetization_potential=config.get("potential", "medium")
|
||||
)
|
||||
|
||||
def detect_geo(self, domain: str) -> Optional[str]:
|
||||
"""
|
||||
Detect geographic targeting from domain name.
|
||||
|
||||
Returns:
|
||||
ISO country code if detected (e.g., "CH", "DE"), None otherwise
|
||||
"""
|
||||
domain_clean = self._normalize_domain(domain)
|
||||
parts = set(self._split_domain_parts(domain_clean))
|
||||
|
||||
# Check TLD first
|
||||
if domain.endswith(".ch") or domain.endswith(".swiss"):
|
||||
return "CH"
|
||||
if domain.endswith(".de"):
|
||||
return "DE"
|
||||
if domain.endswith(".at"):
|
||||
return "AT"
|
||||
|
||||
# Check city names
|
||||
if parts & self.swiss_cities:
|
||||
return "CH"
|
||||
if parts & self.german_cities:
|
||||
return "DE"
|
||||
|
||||
return None
|
||||
|
||||
def estimate_value(self, domain: str) -> dict:
|
||||
"""
|
||||
Estimate the monetization value of a domain.
|
||||
|
||||
Returns dict with value estimates based on intent and traffic potential.
|
||||
"""
|
||||
intent = self.detect(domain)
|
||||
geo = self.detect_geo(domain)
|
||||
|
||||
# Base value by potential
|
||||
base_values = {
|
||||
"high": {"min": 50, "max": 500},
|
||||
"medium": {"min": 20, "max": 100},
|
||||
"low": {"min": 5, "max": 30}
|
||||
}
|
||||
|
||||
potential = intent.monetization_potential
|
||||
base = base_values.get(potential, base_values["low"])
|
||||
|
||||
# Adjust for geo (Swiss = premium)
|
||||
multiplier = 1.5 if geo == "CH" else 1.0
|
||||
|
||||
# Adjust for confidence
|
||||
confidence_mult = 0.5 + (intent.confidence * 0.5)
|
||||
|
||||
return {
|
||||
"estimated_monthly_min": int(base["min"] * multiplier * confidence_mult),
|
||||
"estimated_monthly_max": int(base["max"] * multiplier * confidence_mult),
|
||||
"currency": "CHF" if geo == "CH" else "EUR",
|
||||
"potential": potential,
|
||||
"confidence": intent.confidence,
|
||||
"geo": geo
|
||||
}
|
||||
|
||||
def _normalize_domain(self, domain: str) -> str:
|
||||
"""Remove TLD and normalize domain string."""
|
||||
# Remove common TLDs
|
||||
domain = re.sub(r'\.(com|net|org|ch|de|at|io|co|info|swiss)$', '', domain.lower())
|
||||
# Replace common separators with space
|
||||
domain = re.sub(r'[-_.]', ' ', domain)
|
||||
return domain.strip()
|
||||
|
||||
def _split_domain_parts(self, domain_clean: str) -> list[str]:
|
||||
"""Split domain into meaningful parts."""
|
||||
# Split on spaces (from separators)
|
||||
parts = domain_clean.split()
|
||||
|
||||
# Also try to split camelCase or compound words
|
||||
expanded = []
|
||||
for part in parts:
|
||||
# Try to find compound word boundaries
|
||||
expanded.append(part)
|
||||
# Add any sub-matches for longer words
|
||||
if len(part) > 6:
|
||||
expanded.extend(self._find_subwords(part))
|
||||
|
||||
return expanded
|
||||
|
||||
def _find_subwords(self, word: str) -> list[str]:
|
||||
"""Find meaningful subwords in compound words."""
|
||||
subwords = []
|
||||
|
||||
# Check if any keywords are contained in this word
|
||||
for config in self.patterns.values():
|
||||
for keyword in config["keywords"]:
|
||||
if keyword in word and keyword != word:
|
||||
subwords.append(keyword)
|
||||
|
||||
return subwords
|
||||
|
||||
def _score_category(self, parts: list[str], config: dict) -> tuple[float, list[str]]:
|
||||
"""
|
||||
Score how well domain parts match a category.
|
||||
|
||||
Returns (score, matched_keywords)
|
||||
"""
|
||||
score = 0.0
|
||||
matched = []
|
||||
|
||||
keywords = set(config.get("keywords", []))
|
||||
patterns = config.get("patterns", [])
|
||||
|
||||
for part in parts:
|
||||
# Exact keyword match
|
||||
if part in keywords:
|
||||
score += 1.0
|
||||
matched.append(part)
|
||||
continue
|
||||
|
||||
# Partial keyword match
|
||||
for kw in keywords:
|
||||
if kw in part or part in kw:
|
||||
score += 0.5
|
||||
matched.append(f"{part}~{kw}")
|
||||
break
|
||||
|
||||
# Regex pattern match
|
||||
for pattern in patterns:
|
||||
if re.match(pattern, part):
|
||||
score += 0.7
|
||||
matched.append(f"{part}@{pattern}")
|
||||
break
|
||||
|
||||
return score, matched
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_detector = None
|
||||
|
||||
def get_intent_detector() -> IntentDetector:
|
||||
"""Get singleton IntentDetector instance."""
|
||||
global _detector
|
||||
if _detector is None:
|
||||
_detector = IntentDetector()
|
||||
return _detector
|
||||
|
||||
|
||||
def detect_domain_intent(domain: str) -> IntentResult:
|
||||
"""Convenience function to detect intent for a domain."""
|
||||
return get_intent_detector().detect(domain)
|
||||
|
||||
|
||||
def estimate_domain_yield(domain: str) -> dict:
|
||||
"""Convenience function to estimate yield value for a domain."""
|
||||
detector = get_intent_detector()
|
||||
intent = detector.detect(domain)
|
||||
value = detector.estimate_value(domain)
|
||||
|
||||
return {
|
||||
"domain": domain,
|
||||
"intent": {
|
||||
"category": intent.category,
|
||||
"subcategory": intent.subcategory,
|
||||
"confidence": intent.confidence,
|
||||
"keywords": intent.keywords_matched
|
||||
},
|
||||
"value": value,
|
||||
"partners": intent.suggested_partners,
|
||||
"monetization_potential": intent.monetization_potential
|
||||
}
|
||||
|
||||
@ -71,7 +71,7 @@ class PriceTracker:
|
||||
changes: list[PriceChange] = []
|
||||
now = datetime.utcnow()
|
||||
cutoff = now - timedelta(hours=hours)
|
||||
|
||||
|
||||
# PERF: Avoid N+1 queries (distinct(tld, registrar) + per-pair LIMIT 2).
|
||||
# We fetch the latest 2 rows per (tld, registrar) using a window function.
|
||||
ranked = (
|
||||
@ -97,7 +97,7 @@ class PriceTracker:
|
||||
ranked.c.price,
|
||||
ranked.c.recorded_at,
|
||||
ranked.c.rn,
|
||||
)
|
||||
)
|
||||
.where(ranked.c.rn <= 2)
|
||||
.order_by(ranked.c.tld, ranked.c.registrar, ranked.c.rn)
|
||||
)
|
||||
@ -109,29 +109,29 @@ class PriceTracker:
|
||||
pair = list(grp)
|
||||
if len(pair) < 2:
|
||||
continue
|
||||
|
||||
|
||||
newest = pair[0] if pair[0].rn == 1 else pair[1]
|
||||
previous = pair[1] if pair[0].rn == 1 else pair[0]
|
||||
|
||||
|
||||
# Only consider if the newest price is within the requested window
|
||||
if newest.recorded_at is None or newest.recorded_at < cutoff:
|
||||
continue
|
||||
|
||||
|
||||
if not previous.price or previous.price == 0:
|
||||
continue
|
||||
|
||||
|
||||
change_amount = float(newest.price) - float(previous.price)
|
||||
change_percent = (change_amount / float(previous.price)) * 100
|
||||
|
||||
|
||||
if abs(change_percent) >= self.SIGNIFICANT_CHANGE_THRESHOLD:
|
||||
changes.append(
|
||||
PriceChange(
|
||||
tld=tld,
|
||||
registrar=registrar,
|
||||
tld=tld,
|
||||
registrar=registrar,
|
||||
old_price=float(previous.price),
|
||||
new_price=float(newest.price),
|
||||
change_amount=change_amount,
|
||||
change_percent=change_percent,
|
||||
change_amount=change_amount,
|
||||
change_percent=change_percent,
|
||||
detected_at=newest.recorded_at,
|
||||
)
|
||||
)
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
Tag,
|
||||
AlertTriangle,
|
||||
Briefcase,
|
||||
Coins,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -483,7 +484,7 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 lg:gap-8">
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6 lg:gap-8">
|
||||
{/* For Sale Marketplace */}
|
||||
<div className="group relative p-8 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent
|
||||
border border-accent/20 rounded-3xl hover:border-accent/40 transition-all duration-500
|
||||
@ -620,6 +621,54 @@ export default function HomePage() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* YIELD - Passive Income */}
|
||||
<div className="group relative p-8 bg-gradient-to-br from-purple-500/10 via-purple-500/5 to-transparent
|
||||
border border-purple-500/20 rounded-3xl hover:border-purple-500/40 transition-all duration-500
|
||||
backdrop-blur-sm">
|
||||
<div className="absolute top-0 right-0">
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-purple-500/20 text-purple-400 text-[10px] font-semibold uppercase tracking-wider rounded-bl-xl rounded-tr-3xl">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
New
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start gap-4 mb-5">
|
||||
<div className="w-12 h-12 bg-purple-500/20 border border-purple-500/30 rounded-xl flex items-center justify-center shadow-lg shadow-purple-500/10">
|
||||
<Coins className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-display text-foreground mb-0.5">Yield</h3>
|
||||
<p className="text-xs text-purple-400 font-medium">Passive Income</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-muted mb-5 leading-relaxed">
|
||||
Turn parked domains into passive income via intent routing.
|
||||
</p>
|
||||
<ul className="space-y-2 text-xs mb-6">
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Target className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
|
||||
<span>AI-powered intent detection</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<TrendingUp className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
|
||||
<span>70% revenue share</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-foreground-subtle">
|
||||
<Shield className="w-3.5 h-3.5 text-purple-400 flex-shrink-0" />
|
||||
<span>Verified affiliate partners</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="/terminal/yield"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-500 text-white text-sm font-medium rounded-lg hover:bg-purple-400 transition-all"
|
||||
>
|
||||
Activate
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -434,8 +434,8 @@ export default function WatchlistPage() {
|
||||
// API returns string keys; JS handles number indexing transparently.
|
||||
setHealthReports(prev => ({ ...(prev as any), ...(data.reports as any) }))
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - health data is optional
|
||||
} catch {
|
||||
// Silently fail - health data is optional
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
636
frontend/src/app/terminal/yield/page.tsx
Normal file
636
frontend/src/app/terminal/yield/page.tsx
Normal file
@ -0,0 +1,636 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Zap,
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ArrowUpRight,
|
||||
MousePointer,
|
||||
Target,
|
||||
Wallet,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Check,
|
||||
ExternalLink,
|
||||
XCircle,
|
||||
Sparkles,
|
||||
Loader2
|
||||
} from 'lucide-react'
|
||||
import { api, YieldDomain, YieldTransaction } from '@/lib/api'
|
||||
import { useStore } from '@/lib/store'
|
||||
|
||||
// Stats Card Component
|
||||
function StatsCard({
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
icon: Icon,
|
||||
trend,
|
||||
color = 'emerald'
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
subValue?: string
|
||||
icon: any
|
||||
trend?: number
|
||||
color?: 'emerald' | 'blue' | 'amber' | 'purple'
|
||||
}) {
|
||||
const colorClasses = {
|
||||
emerald: 'from-emerald-500/20 to-emerald-500/5 text-emerald-400 border-emerald-500/30',
|
||||
blue: 'from-blue-500/20 to-blue-500/5 text-blue-400 border-blue-500/30',
|
||||
amber: 'from-amber-500/20 to-amber-500/5 text-amber-400 border-amber-500/30',
|
||||
purple: 'from-purple-500/20 to-purple-500/5 text-purple-400 border-purple-500/30',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
relative overflow-hidden rounded-xl border bg-gradient-to-br p-5
|
||||
${colorClasses[color]}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-zinc-400 mb-1">{label}</p>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
{subValue && (
|
||||
<p className="text-sm text-zinc-400 mt-1">{subValue}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-black/20">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{trend !== undefined && (
|
||||
<div className={`mt-3 flex items-center gap-1 text-xs ${trend >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
<ArrowUpRight className={`w-3 h-3 ${trend < 0 ? 'rotate-180' : ''}`} />
|
||||
<span>{Math.abs(trend)}% vs last month</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Domain Status Badge
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { color: string; icon: any }> = {
|
||||
active: { color: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', icon: CheckCircle2 },
|
||||
pending: { color: 'bg-amber-500/20 text-amber-400 border-amber-500/30', icon: Clock },
|
||||
verifying: { color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: RefreshCw },
|
||||
paused: { color: 'bg-zinc-500/20 text-zinc-400 border-zinc-500/30', icon: AlertCircle },
|
||||
error: { color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: XCircle },
|
||||
}
|
||||
|
||||
const { color, icon: Icon } = config[status] || config.pending
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs border ${color}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Activate Domain Modal
|
||||
function ActivateModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [step, setStep] = useState<'input' | 'analyze' | 'dns' | 'done'>('input')
|
||||
const [domain, setDomain] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [analysis, setAnalysis] = useState<any>(null)
|
||||
const [dnsInstructions, setDnsInstructions] = useState<any>(null)
|
||||
const [copied, setCopied] = useState<string | null>(null)
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!domain.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await api.analyzeYieldDomain(domain.trim())
|
||||
setAnalysis(result)
|
||||
setStep('analyze')
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to analyze domain')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivate = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await api.activateYieldDomain(domain.trim(), true)
|
||||
setDnsInstructions(result.dns_instructions)
|
||||
setStep('dns')
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to activate domain')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string, key: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(key)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
|
||||
const handleDone = () => {
|
||||
onSuccess()
|
||||
onClose()
|
||||
setStep('input')
|
||||
setDomain('')
|
||||
setAnalysis(null)
|
||||
setDnsInstructions(null)
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
|
||||
<div className="relative bg-zinc-900 border border-zinc-800 rounded-2xl w-full max-w-lg mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/20">
|
||||
<Sparkles className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Activate Domain for Yield</h2>
|
||||
<p className="text-sm text-zinc-400">Turn your parked domains into passive income</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{step === 'input' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-2">Domain Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
placeholder="e.g. zahnarzt-zuerich.ch"
|
||||
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-lg text-white placeholder:text-zinc-500 focus:outline-none focus:border-emerald-500"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAnalyze()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={loading || !domain.trim()}
|
||||
className="w-full py-3 px-4 bg-emerald-600 hover:bg-emerald-500 disabled:bg-zinc-700 disabled:text-zinc-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4" />
|
||||
Analyze Intent
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'analyze' && analysis && (
|
||||
<div className="space-y-5">
|
||||
{/* Intent Detection Results */}
|
||||
<div className="p-4 rounded-xl bg-zinc-800/50 border border-zinc-700">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-zinc-400">Detected Intent</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
analysis.intent.confidence > 0.7 ? 'bg-emerald-500/20 text-emerald-400' :
|
||||
analysis.intent.confidence > 0.4 ? 'bg-amber-500/20 text-amber-400' :
|
||||
'bg-zinc-500/20 text-zinc-400'
|
||||
}`}>
|
||||
{Math.round(analysis.intent.confidence * 100)}% confidence
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white capitalize">
|
||||
{analysis.intent.category.replace('_', ' ')}
|
||||
{analysis.intent.subcategory && (
|
||||
<span className="text-zinc-400"> / {analysis.intent.subcategory.replace('_', ' ')}</span>
|
||||
)}
|
||||
</p>
|
||||
{analysis.intent.keywords_matched.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{analysis.intent.keywords_matched.map((kw: string, i: number) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-zinc-700 rounded text-xs text-zinc-300">
|
||||
{kw.split('~')[0]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value Estimate */}
|
||||
<div className="p-4 rounded-xl bg-gradient-to-br from-emerald-500/10 to-transparent border border-emerald-500/30">
|
||||
<span className="text-sm text-zinc-400">Estimated Monthly Revenue</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-3xl font-bold text-emerald-400">
|
||||
{analysis.value.currency} {analysis.value.estimated_monthly_min}
|
||||
</span>
|
||||
<span className="text-zinc-400">-</span>
|
||||
<span className="text-3xl font-bold text-emerald-400">
|
||||
{analysis.value.estimated_monthly_max}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-2">
|
||||
Based on intent category, geo-targeting, and partner rates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Monetization Potential */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800/50">
|
||||
<span className="text-sm text-zinc-400">Monetization Potential</span>
|
||||
<span className={`font-medium ${
|
||||
analysis.monetization_potential === 'high' ? 'text-emerald-400' :
|
||||
analysis.monetization_potential === 'medium' ? 'text-amber-400' :
|
||||
'text-zinc-400'
|
||||
}`}>
|
||||
{analysis.monetization_potential.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep('input')}
|
||||
className="flex-1 py-3 px-4 bg-zinc-800 hover:bg-zinc-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleActivate}
|
||||
disabled={loading}
|
||||
className="flex-1 py-3 px-4 bg-emerald-600 hover:bg-emerald-500 disabled:bg-zinc-700 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Activating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Activate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'dns' && dnsInstructions && (
|
||||
<div className="space-y-5">
|
||||
<div className="text-center mb-4">
|
||||
<div className="w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<CheckCircle2 className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white">Domain Registered!</h3>
|
||||
<p className="text-sm text-zinc-400 mt-1">Complete DNS setup to start earning</p>
|
||||
</div>
|
||||
|
||||
{/* Nameserver Option */}
|
||||
<div className="p-4 rounded-xl bg-zinc-800/50 border border-zinc-700">
|
||||
<h4 className="text-sm font-medium text-white mb-3">Option 1: Nameservers</h4>
|
||||
<p className="text-xs text-zinc-500 mb-3">Point your domain to our nameservers:</p>
|
||||
<div className="space-y-2">
|
||||
{dnsInstructions.nameservers.map((ns: string, i: number) => (
|
||||
<div key={i} className="flex items-center justify-between p-2 rounded bg-zinc-900">
|
||||
<code className="text-sm text-emerald-400">{ns}</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(ns, `ns-${i}`)}
|
||||
className="p-1 hover:bg-zinc-700 rounded"
|
||||
>
|
||||
{copied === `ns-${i}` ? (
|
||||
<Check className="w-4 h-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CNAME Option */}
|
||||
<div className="p-4 rounded-xl bg-zinc-800/50 border border-zinc-700">
|
||||
<h4 className="text-sm font-medium text-white mb-3">Option 2: CNAME Record</h4>
|
||||
<p className="text-xs text-zinc-500 mb-3">Or add a CNAME record:</p>
|
||||
<div className="flex items-center justify-between p-2 rounded bg-zinc-900">
|
||||
<div>
|
||||
<span className="text-xs text-zinc-500">Host: </span>
|
||||
<code className="text-sm text-white">{dnsInstructions.cname_host}</code>
|
||||
<span className="text-xs text-zinc-500 mx-2">→</span>
|
||||
<code className="text-sm text-emerald-400">{dnsInstructions.cname_target}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => copyToClipboard(dnsInstructions.cname_target, 'cname')}
|
||||
className="p-1 hover:bg-zinc-700 rounded"
|
||||
>
|
||||
{copied === 'cname' ? (
|
||||
<Check className="w-4 h-4 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDone}
|
||||
className="w-full py-3 px-4 bg-emerald-600 hover:bg-emerald-500 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Yield Page
|
||||
export default function YieldPage() {
|
||||
const { subscription } = useStore()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dashboard, setDashboard] = useState<any>(null)
|
||||
const [showActivateModal, setShowActivateModal] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchDashboard = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getYieldDashboard()
|
||||
setDashboard(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load yield dashboard:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard()
|
||||
}, [fetchDashboard])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true)
|
||||
fetchDashboard()
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="w-10 h-10 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-zinc-400">Loading yield dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stats = dashboard?.stats
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-gradient-to-br from-emerald-500/20 to-purple-500/20">
|
||||
<TrendingUp className="w-6 h-6 text-emerald-400" />
|
||||
</div>
|
||||
Yield
|
||||
</h1>
|
||||
<p className="text-zinc-400 mt-1">Turn parked domains into passive income</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowActivateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Domain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatsCard
|
||||
label="Monthly Revenue"
|
||||
value={`${stats.currency} ${stats.monthly_revenue.toLocaleString()}`}
|
||||
subValue={`Lifetime: ${stats.currency} ${stats.lifetime_revenue.toLocaleString()}`}
|
||||
icon={DollarSign}
|
||||
color="emerald"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Active Domains"
|
||||
value={stats.active_domains}
|
||||
subValue={`${stats.pending_domains} pending`}
|
||||
icon={Zap}
|
||||
color="blue"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Monthly Clicks"
|
||||
value={stats.monthly_clicks.toLocaleString()}
|
||||
subValue={`${stats.monthly_conversions} conversions`}
|
||||
icon={MousePointer}
|
||||
color="amber"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Pending Payout"
|
||||
value={`${stats.currency} ${stats.pending_payout.toLocaleString()}`}
|
||||
subValue={stats.next_payout_date ? `Next: ${new Date(stats.next_payout_date).toLocaleDateString()}` : undefined}
|
||||
icon={Wallet}
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domains Table */}
|
||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Your Yield Domains</h2>
|
||||
<span className="text-sm text-zinc-400">{dashboard?.domains?.length || 0} domains</span>
|
||||
</div>
|
||||
|
||||
{dashboard?.domains?.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
<TrendingUp className="w-8 h-8 text-zinc-600" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">No yield domains yet</h3>
|
||||
<p className="text-zinc-400 mb-6 max-w-md mx-auto">
|
||||
Activate your first domain to start generating passive income from visitor intent routing.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowActivateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-emerald-600 hover:bg-emerald-500 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Your First Domain
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase tracking-wider text-zinc-500 bg-zinc-800/50">
|
||||
<th className="px-4 py-3">Domain</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Intent</th>
|
||||
<th className="px-4 py-3">Clicks</th>
|
||||
<th className="px-4 py-3">Conversions</th>
|
||||
<th className="px-4 py-3 text-right">Revenue</th>
|
||||
<th className="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{dashboard?.domains?.map((domain: YieldDomain) => (
|
||||
<tr key={domain.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-zinc-800 flex items-center justify-center text-emerald-400 text-xs font-bold">
|
||||
{domain.domain.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="font-medium text-white">{domain.domain}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<StatusBadge status={domain.status} />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<span className="text-sm text-zinc-300 capitalize">
|
||||
{domain.detected_intent?.replace('_', ' ') || '-'}
|
||||
</span>
|
||||
{domain.intent_confidence > 0 && (
|
||||
<span className="text-xs text-zinc-500 ml-2">
|
||||
({Math.round(domain.intent_confidence * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-zinc-300">
|
||||
{domain.total_clicks.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-zinc-300">
|
||||
{domain.total_conversions.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-4 text-right">
|
||||
<span className="font-medium text-emerald-400">
|
||||
{domain.currency} {domain.total_revenue.toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-right">
|
||||
<button className="p-1.5 hover:bg-zinc-700 rounded-lg transition-colors">
|
||||
<ChevronRight className="w-4 h-4 text-zinc-400" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Transactions */}
|
||||
{dashboard?.recent_transactions?.length > 0 && (
|
||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-zinc-800">
|
||||
<h2 className="text-lg font-semibold text-white">Recent Activity</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{dashboard.recent_transactions.slice(0, 5).map((tx: YieldTransaction) => (
|
||||
<div key={tx.id} className="p-4 flex items-center justify-between hover:bg-zinc-800/30 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
tx.event_type === 'sale' ? 'bg-emerald-500/20' :
|
||||
tx.event_type === 'lead' ? 'bg-blue-500/20' :
|
||||
'bg-zinc-700'
|
||||
}`}>
|
||||
{tx.event_type === 'sale' ? (
|
||||
<DollarSign className="w-4 h-4 text-emerald-400" />
|
||||
) : tx.event_type === 'lead' ? (
|
||||
<Target className="w-4 h-4 text-blue-400" />
|
||||
) : (
|
||||
<MousePointer className="w-4 h-4 text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white capitalize">{tx.event_type}</p>
|
||||
<p className="text-xs text-zinc-500">{tx.partner_slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-emerald-400">
|
||||
+{tx.currency} {tx.net_amount.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{new Date(tx.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activate Modal */}
|
||||
<ActivateModal
|
||||
isOpen={showActivateModal}
|
||||
onClose={() => setShowActivateModal(false)}
|
||||
onSuccess={fetchDashboard}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
Sparkles,
|
||||
Tag,
|
||||
Target,
|
||||
Coins,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@ -120,6 +121,23 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
},
|
||||
]
|
||||
|
||||
// SECTION 3: Monetize - Passive income features
|
||||
const monetizeItems: Array<{
|
||||
href: string
|
||||
label: string
|
||||
icon: any
|
||||
badge: number | null
|
||||
isNew?: boolean
|
||||
}> = [
|
||||
{
|
||||
href: '/terminal/yield',
|
||||
label: 'YIELD',
|
||||
icon: Coins,
|
||||
badge: null,
|
||||
isNew: true,
|
||||
},
|
||||
]
|
||||
|
||||
const bottomItems = [
|
||||
{ href: '/terminal/settings', label: 'Settings', icon: Settings },
|
||||
]
|
||||
@ -276,6 +294,58 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SECTION 3: Monetize */}
|
||||
<div className={clsx("mt-6", collapsed ? "px-1" : "px-2")}>
|
||||
{!collapsed && (
|
||||
<p className="text-[10px] font-bold text-zinc-600 uppercase tracking-widest mb-3 pl-2">
|
||||
Monetize
|
||||
</p>
|
||||
)}
|
||||
{collapsed && <div className="h-px bg-white/5 mb-3 w-4 mx-auto" />}
|
||||
|
||||
<div className="space-y-1">
|
||||
{monetizeItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={clsx(
|
||||
"group relative flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200",
|
||||
isActive(item.href)
|
||||
? "text-emerald-400 bg-emerald-500/[0.08]"
|
||||
: "text-zinc-400 hover:text-zinc-200 hover:bg-white/[0.03]"
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
{isActive(item.href) && (
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-emerald-500 rounded-full shadow-[0_0_8px_rgba(16,185,129,0.5)]" />
|
||||
)}
|
||||
<div className="relative">
|
||||
<item.icon className={clsx(
|
||||
"w-4 h-4 transition-all duration-300",
|
||||
isActive(item.href)
|
||||
? "text-emerald-400"
|
||||
: "group-hover:text-zinc-200"
|
||||
)} />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className={clsx(
|
||||
"text-xs font-semibold tracking-wide transition-colors flex-1",
|
||||
isActive(item.href) ? "text-emerald-400" : "text-zinc-400 group-hover:text-zinc-200"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{item.isNew && !collapsed && (
|
||||
<span className="px-1.5 py-0.5 text-[8px] font-bold uppercase tracking-wider bg-emerald-500/20 text-emerald-400 rounded">
|
||||
New
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Bottom Section */}
|
||||
|
||||
@ -1263,6 +1263,239 @@ class AdminApiClient extends ApiClient {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// YIELD / Intent Routing API
|
||||
// =========================================================================
|
||||
|
||||
async analyzeYieldDomain(domain: string) {
|
||||
return this.request<{
|
||||
domain: string
|
||||
intent: {
|
||||
category: string
|
||||
subcategory: string | null
|
||||
confidence: number
|
||||
keywords_matched: string[]
|
||||
suggested_partners: string[]
|
||||
monetization_potential: 'high' | 'medium' | 'low'
|
||||
}
|
||||
value: {
|
||||
estimated_monthly_min: number
|
||||
estimated_monthly_max: number
|
||||
currency: string
|
||||
potential: string
|
||||
confidence: number
|
||||
geo: string | null
|
||||
}
|
||||
partners: string[]
|
||||
monetization_potential: string
|
||||
}>(`/yield/analyze?domain=${encodeURIComponent(domain)}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async getYieldDashboard() {
|
||||
return this.request<{
|
||||
stats: {
|
||||
total_domains: number
|
||||
active_domains: number
|
||||
pending_domains: number
|
||||
monthly_revenue: number
|
||||
monthly_clicks: number
|
||||
monthly_conversions: number
|
||||
lifetime_revenue: number
|
||||
lifetime_clicks: number
|
||||
lifetime_conversions: number
|
||||
pending_payout: number
|
||||
next_payout_date: string | null
|
||||
currency: string
|
||||
}
|
||||
domains: YieldDomain[]
|
||||
recent_transactions: YieldTransaction[]
|
||||
top_domains: YieldDomain[]
|
||||
}>('/yield/dashboard')
|
||||
}
|
||||
|
||||
async getYieldDomains(params?: { status?: string; limit?: number; offset?: number }) {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.status) queryParams.set('status', params.status)
|
||||
if (params?.limit) queryParams.set('limit', params.limit.toString())
|
||||
if (params?.offset) queryParams.set('offset', params.offset.toString())
|
||||
|
||||
return this.request<{
|
||||
domains: YieldDomain[]
|
||||
total: number
|
||||
total_active: number
|
||||
total_revenue: number
|
||||
total_clicks: number
|
||||
}>(`/yield/domains?${queryParams}`)
|
||||
}
|
||||
|
||||
async getYieldDomain(domainId: number) {
|
||||
return this.request<YieldDomain>(`/yield/domains/${domainId}`)
|
||||
}
|
||||
|
||||
async activateYieldDomain(domain: string, acceptTerms: boolean = true) {
|
||||
return this.request<{
|
||||
domain_id: number
|
||||
domain: string
|
||||
status: string
|
||||
intent: {
|
||||
category: string
|
||||
subcategory: string | null
|
||||
confidence: number
|
||||
keywords_matched: string[]
|
||||
suggested_partners: string[]
|
||||
monetization_potential: string
|
||||
}
|
||||
value_estimate: {
|
||||
estimated_monthly_min: number
|
||||
estimated_monthly_max: number
|
||||
currency: string
|
||||
potential: string
|
||||
confidence: number
|
||||
geo: string | null
|
||||
}
|
||||
dns_instructions: {
|
||||
domain: string
|
||||
nameservers: string[]
|
||||
cname_host: string
|
||||
cname_target: string
|
||||
verification_url: string
|
||||
}
|
||||
message: string
|
||||
}>('/yield/activate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ domain, accept_terms: acceptTerms }),
|
||||
})
|
||||
}
|
||||
|
||||
async verifyYieldDomainDNS(domainId: number) {
|
||||
return this.request<{
|
||||
domain: string
|
||||
verified: boolean
|
||||
expected_ns: string[]
|
||||
actual_ns: string[]
|
||||
cname_ok: boolean
|
||||
error: string | null
|
||||
checked_at: string
|
||||
}>(`/yield/domains/${domainId}/verify`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async updateYieldDomain(domainId: number, data: {
|
||||
active_route?: string
|
||||
landing_page_url?: string
|
||||
status?: string
|
||||
}) {
|
||||
return this.request<YieldDomain>(`/yield/domains/${domainId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteYieldDomain(domainId: number) {
|
||||
return this.request<{ message: string }>(`/yield/domains/${domainId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async getYieldTransactions(params?: {
|
||||
domain_id?: number
|
||||
status?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}) {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.domain_id) queryParams.set('domain_id', params.domain_id.toString())
|
||||
if (params?.status) queryParams.set('status', params.status)
|
||||
if (params?.limit) queryParams.set('limit', params.limit.toString())
|
||||
if (params?.offset) queryParams.set('offset', params.offset.toString())
|
||||
|
||||
return this.request<{
|
||||
transactions: YieldTransaction[]
|
||||
total: number
|
||||
total_gross: number
|
||||
total_net: number
|
||||
}>(`/yield/transactions?${queryParams}`)
|
||||
}
|
||||
|
||||
async getYieldPayouts(params?: { status?: string; limit?: number; offset?: number }) {
|
||||
const queryParams = new URLSearchParams()
|
||||
if (params?.status) queryParams.set('status', params.status)
|
||||
if (params?.limit) queryParams.set('limit', params.limit.toString())
|
||||
if (params?.offset) queryParams.set('offset', params.offset.toString())
|
||||
|
||||
return this.request<{
|
||||
payouts: YieldPayout[]
|
||||
total: number
|
||||
total_paid: number
|
||||
total_pending: number
|
||||
}>(`/yield/payouts?${queryParams}`)
|
||||
}
|
||||
|
||||
async getYieldPartners(category?: string) {
|
||||
const params = category ? `?category=${encodeURIComponent(category)}` : ''
|
||||
return this.request<YieldPartner[]>(`/yield/partners${params}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Yield Types
|
||||
export interface YieldDomain {
|
||||
id: number
|
||||
domain: string
|
||||
status: 'pending' | 'verifying' | 'active' | 'paused' | 'inactive' | 'error'
|
||||
detected_intent: string | null
|
||||
intent_confidence: number
|
||||
active_route: string | null
|
||||
partner_name: string | null
|
||||
dns_verified: boolean
|
||||
dns_verified_at: string | null
|
||||
total_clicks: number
|
||||
total_conversions: number
|
||||
total_revenue: number
|
||||
currency: string
|
||||
activated_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface YieldTransaction {
|
||||
id: number
|
||||
event_type: 'click' | 'lead' | 'sale'
|
||||
partner_slug: string
|
||||
gross_amount: number
|
||||
net_amount: number
|
||||
currency: string
|
||||
status: 'pending' | 'confirmed' | 'paid' | 'rejected'
|
||||
geo_country: string | null
|
||||
created_at: string
|
||||
confirmed_at: string | null
|
||||
}
|
||||
|
||||
export interface YieldPayout {
|
||||
id: number
|
||||
amount: number
|
||||
currency: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
transaction_count: number
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
payment_method: string | null
|
||||
payment_reference: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface YieldPartner {
|
||||
slug: string
|
||||
name: string
|
||||
network: string
|
||||
intent_categories: string[]
|
||||
geo_countries: string[]
|
||||
payout_type: string
|
||||
description: string | null
|
||||
logo_url: string | null
|
||||
}
|
||||
|
||||
export const api = new AdminApiClient()
|
||||
|
||||
@ -125,14 +125,14 @@ export const useStore = create<AppState>((set, get) => ({
|
||||
set({ isLoading: true })
|
||||
try {
|
||||
// Cookie-based auth: if cookie is present and valid, /auth/me succeeds.
|
||||
const user = await api.getMe()
|
||||
set({ user, isAuthenticated: true })
|
||||
|
||||
// Fetch in parallel for speed
|
||||
await Promise.all([
|
||||
get().fetchDomains(),
|
||||
get().fetchSubscription()
|
||||
])
|
||||
const user = await api.getMe()
|
||||
set({ user, isAuthenticated: true })
|
||||
|
||||
// Fetch in parallel for speed
|
||||
await Promise.all([
|
||||
get().fetchDomains(),
|
||||
get().fetchSubscription()
|
||||
])
|
||||
} catch {
|
||||
set({ user: null, isAuthenticated: false })
|
||||
} finally {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
│ Next.js App │◀────▶│ FastAPI API │
|
||||
│ (Port 3000) │ │ (Port 8000) │
|
||||
└─────────────────┘ └──────────┬─────────┘
|
||||
│
|
||||
│
|
||||
┌───────────────┼────────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌────▼────┐ ┌─────▼─────┐
|
||||
@ -56,7 +56,7 @@ Separate processes (recommended):
|
||||
## Scheduler Pattern
|
||||
```
|
||||
APScheduler (AsyncIO mode) in separate scheduler process
|
||||
│
|
||||
│
|
||||
├── Domain checks (tier-based frequency)
|
||||
├── TLD price scrape + change detection
|
||||
├── Auction scrape + cleanup
|
||||
|
||||
114
pounce_endgame.md
Normal file
114
pounce_endgame.md
Normal file
@ -0,0 +1,114 @@
|
||||
Das ist das **"Endgame"-Konzept**. Damit schließt sich der Kreis.
|
||||
|
||||
Du hast jetzt nicht nur ein Tool für den **Handel** (Trading), sondern für den **Betrieb** (Operations) der Asset-Klasse.
|
||||
Damit wird Pounce zur **"Verwaltungsgesellschaft für digitales Grundeigentum"**.
|
||||
|
||||
Hier ist das **integrierte Unicorn-Gesamtkonzept**, das deine Trading-Plattform mit dieser Yield-Maschine verschmilzt.
|
||||
|
||||
---
|
||||
|
||||
### Das neue Pounce Ökosystem: "The Domain Lifecycle Engine"
|
||||
|
||||
Wir teilen Pounce nun final in **4 operative Module**. Das Konzept "Intent Routing" wird zum Herzstück von Modul 3.
|
||||
|
||||
#### 1. DISCOVER (Intelligence)
|
||||
*„Finde das Asset.“*
|
||||
* **Funktion:** TLD Preise, Drop-Listen, Spam-Filter.
|
||||
* **Mehrwert:** Wir wissen, was verfügbar ist und was es historisch wert war.
|
||||
|
||||
#### 2. ACQUIRE (Marketplace)
|
||||
*„Sichere das Asset.“*
|
||||
* **Funktion:** Aggregierte Auktionen, Pounce Direct (Verifizierte User-Sales).
|
||||
* **Mehrwert:** Spam-freier Zugang, schnelle Abwicklung, sichere Transfers.
|
||||
|
||||
#### 3. YIELD (Intent Routing) — *NEU & INTEGRIERT*
|
||||
*„Lass das Asset arbeiten.“*
|
||||
* **Die Vision:** Verwandlung von toten Namen in aktive "Intent Router".
|
||||
* **Der Mechanismus:**
|
||||
1. **Connect:** User ändert Nameserver auf `ns.pounce.io`.
|
||||
2. **Analyze:** Pounce erkennt `zahnarzt-zuerich.ch` → Intent: "Termin buchen".
|
||||
3. **Route:** Pounce schaltet automatisch eine Minimal-Seite ("Zahnarzt finden") und leitet Traffic zu Partnern (Doctolib, Comparis) weiter.
|
||||
* **Der Clou:** Kein "Parking" (Werbung für 0,01€), sondern "Routing" (Leads für 20,00€).
|
||||
* **Automatisierung:** Der User macht **nichts**. Pounce wählt Partner, baut die Seite, trackt die Einnahmen.
|
||||
|
||||
#### 4. TRADE (Liquidity)
|
||||
*„Verkaufe die Performance.“*
|
||||
* **Die Revolution:** Domains werden nicht mehr nach "Klang" bewertet, sondern nach **Yield (Rendite)**.
|
||||
* **Marktplatz-Logik:** "Verkaufe `ferien-zermatt.ch`. Einnahmen: $150/Monat (Routing zu Booking.com). Preis: $4.500 (30x Monatsumsatz)."
|
||||
|
||||
---
|
||||
|
||||
### Wie das „Yield“-Feature im Terminal aussieht (UX)
|
||||
|
||||
Wir integrieren das Konzept nahtlos in dein Dashboard.
|
||||
|
||||
**Tab: PORTFOLIO**
|
||||
|
||||
| Domain | Status | Intent Detected | Active Route | Yield (MRR) | Action |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **zahnarzt-zh.ch** | 🟢 Active | 🏥 Dentist Booking | ➔ Comparis | **CHF 124.00** | [Manage] |
|
||||
| **crm-tool.io** | 🟢 Active | 💻 SaaS Trial | ➔ HubSpot | **$ 45.00** | [Manage] |
|
||||
| **mein-blog.de** | ⚪ Idle | ❓ Unclear | — | — | **[Activate]** |
|
||||
|
||||
**Der "Activate"-Flow (One-Click):**
|
||||
1. User klickt `[Activate]`.
|
||||
2. System scannt: *"Wir haben 'CRM Software' erkannt. Wir empfehlen Routing zu: HubSpot Partner Program."*
|
||||
3. User bestätigt.
|
||||
4. **Fertig.** Die Domain verdient ab sofort Geld.
|
||||
|
||||
---
|
||||
|
||||
### Warum das den Firmenwert (Unicorn) sichert
|
||||
|
||||
Mit diesem Baustein löst du die drei größten Probleme des Domain-Marktes:
|
||||
|
||||
1. **Das "Renewal-Problem":**
|
||||
* *Vorher:* Domains kosten jedes Jahr Geld. User löschen sie, wenn sie nicht verkauft werden.
|
||||
* *Nachher:* Die Domain bezahlt ihre eigene Verlängerung durch Affiliate-Einnahmen. Der User behält sie für immer (Churn sinkt auf 0).
|
||||
|
||||
2. **Das "Bewertungs-Problem":**
|
||||
* *Vorher:* "Ich glaube, die ist $5.000 wert." (Fantasie).
|
||||
* *Nachher:* "Die Domain macht $500 im Jahr. Bei einem 10er-Multiple ist sie $5.000 wert." (Fakt).
|
||||
* **Pounce wird zur Rating-Agentur für digitale Assets.**
|
||||
|
||||
3. **Das "Daten-Monopol":**
|
||||
* Du bist der Einzige, der weiß, welche Domain *wirklich* Traffic und Conversions bringt. Diese Daten sind Gold wert für Werbetreibende.
|
||||
|
||||
---
|
||||
|
||||
### Die Roadmap-Anpassung (Wann bauen wir das?)
|
||||
|
||||
Das kommt in **Phase 2**, direkt nach dem Launch des Marktplatzes.
|
||||
|
||||
**Schritt 1:** Baue die "Intelligence" & "Market" (Das Tool, um Leute anzulocken).
|
||||
**Schritt 2:** Baue "Yield" (Das Feature, um sie zu halten und zu monetarisieren).
|
||||
* Starte mit **einem Vertical** (z.B. "Software/SaaS"). Das ist am einfachsten zu routen.
|
||||
* Schließe Partnerschaften mit 2-3 großen Affiliate-Netzwerken (PartnerStack, Awin).
|
||||
|
||||
**Schritt 3:** Verbinde Yield & Market.
|
||||
* Erlaube Usern, ihre "Yield-Generatoren" auf dem Marktplatz zu verkaufen.
|
||||
* *"Verkaufe Cashflow-positive Domains."*
|
||||
|
||||
---
|
||||
|
||||
### Dein Pitch-Deck Slide für dieses Feature
|
||||
|
||||
**The Problem:**
|
||||
99% of domains are "dead capital". They cost renewal fees but generate zero value until sold. Traditional parking (ads) is broken and pays pennies.
|
||||
|
||||
**The Pounce Solution: Intent Routing.**
|
||||
We turn domains into autonomous agents.
|
||||
1. **Analyze Intent:** We know `kredit.ch` means "Loan Comparison".
|
||||
2. **Route Traffic:** We send users directly to the solution (e.g., Bank).
|
||||
3. **Capture Value:** We split the affiliate commission with the owner.
|
||||
|
||||
**Result:**
|
||||
Domains become **Yield-Bearing Assets**.
|
||||
Pounce becomes the **Asset Manager**.
|
||||
|
||||
---
|
||||
|
||||
Das passt perfekt. Es ist sauber, es ist automatisiert, und es ist extrem skalierbar.
|
||||
Du baust keine Webseiten. Du baust **Wegweiser**. Und für jeden, der den Wegweiser nutzt, kassierst du Maut.
|
||||
|
||||
**Das ist das Unicorn-Modell.** 🦄
|
||||
Reference in New Issue
Block a user