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.sniper_alerts import router as sniper_alerts_router
|
||||||
from app.api.seo import router as seo_router
|
from app.api.seo import router as seo_router
|
||||||
from app.api.dashboard import router as dashboard_router
|
from app.api.dashboard import router as dashboard_router
|
||||||
|
from app.api.yield_domains import router as yield_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
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)
|
# SEO Data / Backlinks - from analysis_3.md (Tycoon-only)
|
||||||
api_router.include_router(seo_router, prefix="/seo", tags=["SEO Data - Tycoon"])
|
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
|
# Support & Communication
|
||||||
api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"])
|
api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Newsletter"])
|
||||||
|
|
||||||
|
|||||||
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
|
||||||
|
|
||||||
@ -12,6 +12,7 @@ from app.models.blog import BlogPost
|
|||||||
from app.models.listing import DomainListing, ListingInquiry, ListingView
|
from app.models.listing import DomainListing, ListingInquiry, ListingView
|
||||||
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||||
from app.models.seo_data import DomainSEOData
|
from app.models.seo_data import DomainSEOData
|
||||||
|
from app.models.yield_domain import YieldDomain, YieldTransaction, YieldPayout, AffiliatePartner
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@ -37,4 +38,9 @@ __all__ = [
|
|||||||
"SniperAlertMatch",
|
"SniperAlertMatch",
|
||||||
# New: SEO Data (Tycoon feature)
|
# New: SEO Data (Tycoon feature)
|
||||||
"DomainSEOData",
|
"DomainSEOData",
|
||||||
|
# New: Yield / Intent Routing
|
||||||
|
"YieldDomain",
|
||||||
|
"YieldTransaction",
|
||||||
|
"YieldPayout",
|
||||||
|
"AffiliatePartner",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -68,6 +68,13 @@ class User(Base):
|
|||||||
sniper_alerts: Mapped[List["SniperAlert"]] = relationship(
|
sniper_alerts: Mapped[List["SniperAlert"]] = relationship(
|
||||||
"SniperAlert", back_populates="user", cascade="all, delete-orphan"
|
"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:
|
def __repr__(self) -> str:
|
||||||
return f"<User {self.email}>"
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@ -33,6 +33,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
|
Coins,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -483,7 +484,7 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* For Sale Marketplace */}
|
||||||
<div className="group relative p-8 bg-gradient-to-br from-accent/10 via-accent/5 to-transparent
|
<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
|
border border-accent/20 rounded-3xl hover:border-accent/40 transition-all duration-500
|
||||||
@ -620,6 +621,54 @@ export default function HomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
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,
|
Sparkles,
|
||||||
Tag,
|
Tag,
|
||||||
Target,
|
Target,
|
||||||
|
Coins,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import clsx from 'clsx'
|
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 = [
|
const bottomItems = [
|
||||||
{ href: '/terminal/settings', label: 'Settings', icon: Settings },
|
{ href: '/terminal/settings', label: 'Settings', icon: Settings },
|
||||||
]
|
]
|
||||||
@ -276,6 +294,58 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom Section */}
|
{/* Bottom Section */}
|
||||||
|
|||||||
@ -1263,6 +1263,239 @@ class AdminApiClient extends ApiClient {
|
|||||||
method: 'POST',
|
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()
|
export const api = new AdminApiClient()
|
||||||
|
|||||||
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