feat: Portfolio improvements, listing/yield integration, UNICORN_PLAN
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

This commit is contained in:
2025-12-15 08:04:03 +01:00
parent acfcab682d
commit 6d7db54021
9 changed files with 643 additions and 126 deletions

230
UNICORN_PLAN.md Normal file
View File

@ -0,0 +1,230 @@
## Pounce Unicorn Plan (integriert)
Ziel: Pounce von einem starken Produkt (Trust + Inventory + Lead Capture) zu einem skalierbaren System mit Moat + Flywheel entwickeln.
---
## Absicht & holistisches Konzept
### Absicht (warum es Pounce gibt)
Pounce existiert, um Domains von „toten Namen“ (nur Renewal-Kosten, keine Nutzung) zu **messbaren, handelbaren digitalen Assets** zu machen.
Wir bauen nicht nur einen Feed oder einen Marktplatz, sondern eine **Lifecycle Engine**: entdecken → erwerben → monetarisieren → liquidieren.
### Für wen (Zielgruppe)
- **Domain Investors / Operators**: brauchen sauberes Inventory, schnelle Entscheidungen, klare Workflows.
- **Builders / Entrepreneurs**: wollen gute Assets finden und sofort nutzen/monetarisieren.
- **Portfolio Owner** (ab 10+ Domains): wollen Governance (Health, Renewal, Cashflow) statt Chaos.
### Positionierung (klarer Satz)
**Pounce ist das Operating System für Domains**: ein Clean Market Feed + Verified Direct Deals + Yield Routing mit Messbarkeit vom ersten View bis zum Exit.
### Das Gesamtmodell (4 Module)
1. **Discover (Intelligence)**
Findet Assets: Clean Feed, Scores, TLD Intel, Filter, Alerts.
2. **Acquire (Marketplace / Liquidity)**
Sichert Assets: externe Auktionen + **Pounce Direct** (DNS-verified Owner).
3. **Yield (Intent Routing)**
Monetarisiert Assets: Domain-Traffic → Intent → Partner → Revenue Share.
4. **Trade (Exit / Outcomes)**
Liquidität und Bewertung: Domains werden nach **Cashflow** bepreist (Multiple), nicht nur nach „Vibe“.
### Warum das Unicorn-Potenzial hat (Moat + Flywheel)
- **Moat**: Proprietäre Daten über Intent, Traffic, Conversion und Cashflow auf Domain-Level (schwer kopierbar).
- **Flywheel**: mehr Domains → mehr Routing/Conversions → mehr Daten → bessere Scores/Routing → mehr Deals → mehr Domains.
---
## 0) Leitprinzipien
- **Moat entsteht dort, wo proprietäre Daten entstehen**: Yield/Intent + Deal Outcomes.
- **Trust ist ein Feature**: alles, was Spam/Scam senkt, steigert Conversion.
- **Telemetry ist nicht „später“**: jede neue Funktion erzeugt Events + messbare KPIs.
---
## 1) DealSystem (Liquidity Loop fertig machen)
### 1A — Inbox Workflow (Woche 1)
**Ziel**: Seller können Leads zuverlässig triagieren und messen.
- **Inquiry Status Workflow komplett**: `new → read → replied → closed` + `spam`
- Backend PATCH Endpoint + UI Actions
- „Close“ inkl. Grund (z.B. sold elsewhere / low offer / no fit)
- **Audit Trail (minimal)**
- jede Statusänderung speichert: `who/when/old/new`
**KPIs**
- inquiry→read rate
- inquiry→replied rate
- median reply time
### 1B — Threading/Negotiation (Woche 23)
**Ziel**: Verhandlung im Produkt, nicht off-platform.
- **Threading**: Buyer ↔ Seller Messages als Conversation pro Listing
- **Notifications**: EMail „New message“ + LoginGate
- **Audit Trail (voll)**: message events + status events
- **Security**: rate limits (buyer + seller), keyword checks, link safety
**KPIs**
- inquiry→first message
- messages/thread
- reply rate
### 1C — Deal Closure + GMV (Woche 34)
**Ziel**: echte Conversion/GMV messbar machen.
- **“Mark as Sold”** auf Listing
- Gründe: sold on Pounce / sold offplatform / removed
- optional: **deal_value** + currency
- optional sauberer **Deal-Record**
- `deal_id`, `listing_id`, `buyer_user_id(optional)`, `final_price`, `closed_at`
**KPIs**
- inquiry→sold
- close rate
- time-to-close
- GMV
### 1D — AntiAbuse (laufend ab Woche 1)
- **Rate limit** pro IP + pro User (inquire + message + status flips)
- **Spam flagging** (Heuristiken + manuell)
- **Blocklist** (buyer account/email/domain-level)
**KPIs**
- spam rate
- blocked attempts
- false positive rate
---
## 2) Yield als Burggraben (Moat)
### 2A — Connect/Nameserver Flow (Woche 24)
**Ziel**: Domains „unter Kontrolle“ bringen (Connect Layer).
- **Connect Wizard** (Portfolio → Yield)
- Anleitung: NS/TXT Setup
- Status: pending/verified/active
- **Backend checks** (NS/TXT) + Speicherung: `connected_at`
- **Routing Entry** (Edge/Web): Request → route decision
**KPIs**
- connect attempts→verified
- connected domains
### 2B — Intent → Routing → Tracking (Monat 2)
**Ziel**: Intent Routing MVP für 1 Vertical.
- **Intent detection** (MVP)
- **Routing** zu Partnern + Fallbacks
- **Tracking**: click_id, domain_id, partner_id
- **Attribution**: conversion mapping + payout status
**KPIs**
- clicks/domain
- conversion rate
- revenue/domain
### 2C — Payout + Revenue Share (Monat 23)
- Ledger: pending → confirmed → paid
- payout schedule (monatlich) + export/reports
**KPIs**
- payout accuracy
- disputes
- net margin
### 2D — Portfolio Cashflow Dashboard (Monat 3)
- Portfolio zeigt: **MRR, last 30d revenue, ROI**, top routes
- Domains werden „yield-bearing assets“ → später handelbar nach Multiple
**KPIs**
- MRR
- retention/churn
- expansion
---
## 3) Flywheel / Distribution
### 3A — Programmatic SEO maximal (Monat 12)
- Templates skalieren (TLD/Intel/Price)
- klare CTAPfade: „Track this TLD“, „Enter Terminal“, „View Direct Deals“
**KPIs**
- organic sessions
- signup conversion
### 3B — Public Deal Surface + Login Gate (Monat 1)
- Public Acquire + /buy als ConversionEngine
- “contact requires login” überall konsistent
**KPIs**
- view→login
- login→inquiry
### 3C — Viral Loop „Powered by Pounce“ (Monat 23)
- nur wenn intent passt / low intent fallback
- referral link + revenue share
**KPIs**
- referral signups
- CAC ~0
---
## 4) Skalierung / Telemetry
### 4A — Events (Woche 12)
Definiere & logge Events:
- `listing_view`
- `inquiry_created`
- `inquiry_status_changed`
- `message_sent`
- `listing_marked_sold`
- `yield_connected`
- `yield_click`
- `yield_conversion`
- `payout_paid`
**KPIs**
- funnel conversion
- time metrics
### 4B — Ops (Monat 1)
- Monitoring/alerts (Errors + Business KPIs)
- Backups (DB daily + restore drill)
- Deliverability (SPF/DKIM/DMARC, bounce handling)
- Abuse monitoring dashboards
---
## Empfohlene Reihenfolge (damit es schnell „unfair“ wird)
1. **Deal-System 1A1C** (GMV & close-rate messbar)
2. **Yield 2A** (Connect Layer) parallel starten
3. **Events 4A** sofort mitziehen
4. **Yield 2B2C** (Moat) sobald Connect stabil
5. Flywheel 3A3C kontinuierlich

View File

@ -188,6 +188,11 @@ class InquiryResponse(BaseModel):
from_attributes = True
class InquiryUpdate(BaseModel):
"""Update inquiry status for listing owner."""
status: str = Field(..., min_length=1, max_length=20) # new, read, replied, spam
class VerificationResponse(BaseModel):
"""DNS verification response."""
verification_code: str
@ -716,6 +721,69 @@ async def get_listing_inquiries(
]
@router.patch("/{id}/inquiries/{inquiry_id}", response_model=InquiryResponse)
async def update_listing_inquiry(
id: int,
inquiry_id: int,
data: InquiryUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Update an inquiry status (listing owner only)."""
allowed = {"new", "read", "replied", "spam"}
status_clean = (data.status or "").strip().lower()
if status_clean not in allowed:
raise HTTPException(status_code=400, detail="Invalid status")
# Verify listing ownership
listing_result = await db.execute(
select(DomainListing).where(
and_(
DomainListing.id == id,
DomainListing.user_id == current_user.id,
)
)
)
listing = listing_result.scalar_one_or_none()
if not listing:
raise HTTPException(status_code=404, detail="Listing not found")
inquiry_result = await db.execute(
select(ListingInquiry).where(
and_(
ListingInquiry.id == inquiry_id,
ListingInquiry.listing_id == id,
)
)
)
inquiry = inquiry_result.scalar_one_or_none()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
now = datetime.utcnow()
inquiry.status = status_clean
if status_clean == "read" and inquiry.read_at is None:
inquiry.read_at = now
if status_clean == "replied":
inquiry.replied_at = now
await db.commit()
await db.refresh(inquiry)
return InquiryResponse(
id=inquiry.id,
name=inquiry.name,
email=inquiry.email,
phone=inquiry.phone,
company=inquiry.company,
message=inquiry.message,
offer_amount=inquiry.offer_amount,
status=inquiry.status,
created_at=inquiry.created_at,
read_at=inquiry.read_at,
)
@router.put("/{id}", response_model=ListingResponse)
async def update_listing(
id: int,

View File

@ -9,7 +9,7 @@ import {
Plus, Shield, Eye, ExternalLink, Loader2, Trash2,
CheckCircle, AlertCircle, Copy, X, Tag, Sparkles,
TrendingUp, Gavel, Target, Menu, Settings, LogOut, Crown, Zap, Coins,
ArrowRight, RefreshCw, Globe, Lock, Briefcase
ArrowRight, RefreshCw, Globe, Lock, Briefcase, MessageSquare, Mail, Phone, Building, Send
} from 'lucide-react'
import Link from 'next/link'
import Image from 'next/image'
@ -39,6 +39,19 @@ interface Listing {
created_at: string
}
interface ListingInquiry {
id: number
name: string
email: string
phone: string | null
company: string | null
message: string
offer_amount: number | null
status: string
created_at: string
read_at: string | null
}
// ============================================================================
// MAIN PAGE
// ============================================================================
@ -52,6 +65,7 @@ export default function MyListingsPage() {
const [loading, setLoading] = useState(true)
const [showCreateWizard, setShowCreateWizard] = useState(false)
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
const [leadsListing, setLeadsListing] = useState<Listing | null>(null)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
@ -321,6 +335,7 @@ export default function MyListingsPage() {
onDelete={() => handleDelete(listing.id, listing.domain)}
onVerify={() => setSelectedListing(listing)}
onPublish={() => handlePublish(listing)}
onLeads={() => setLeadsListing(listing)}
isDeleting={deletingId === listing.id}
/>
))}
@ -389,6 +404,14 @@ export default function MyListingsPage() {
onVerified={() => { loadListings(); setSelectedListing(null) }}
/>
)}
{/* LEADS MODAL */}
{leadsListing && (
<LeadsModal
listing={leadsListing}
onClose={() => setLeadsListing(null)}
/>
)}
</div>
)
}
@ -403,6 +426,7 @@ function ListingRow({
onDelete,
onVerify,
onPublish,
onLeads,
isDeleting
}: {
listing: Listing
@ -410,6 +434,7 @@ function ListingRow({
onDelete: () => void
onVerify: () => void
onPublish: () => void
onLeads: () => void
isDeleting: boolean
}) {
const isDraft = listing.status === 'draft'
@ -458,6 +483,16 @@ function ListingRow({
<ExternalLink className="w-3 h-3" />View
</a>
)}
{listing.inquiry_count > 0 && (
<button
type="button"
onClick={onLeads}
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-accent"
title="View buyer inquiries"
>
<MessageSquare className="w-4 h-4" />
</button>
)}
<button onClick={onDelete} disabled={isDeleting}
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-rose-400">
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
@ -505,6 +540,16 @@ function ListingRow({
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
{listing.inquiry_count > 0 && (
<button
type="button"
onClick={onLeads}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-accent"
title="View buyer inquiries"
>
<MessageSquare className="w-3.5 h-3.5" />
</button>
)}
<button onClick={onDelete} disabled={isDeleting}
className="w-7 h-7 flex items-center justify-center border border-white/10 text-white/30 hover:text-rose-400">
{isDeleting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
@ -515,6 +560,173 @@ function ListingRow({
)
}
// ============================================================================
// LEADS MODAL
// ============================================================================
function LeadsModal({ listing, onClose }: { listing: Listing; onClose: () => void }) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [inquiries, setInquiries] = useState<ListingInquiry[]>([])
const [updatingId, setUpdatingId] = useState<number | null>(null)
useEffect(() => {
let mounted = true
setLoading(true)
setError(null)
api.getListingInquiries(listing.id)
.then((data) => {
if (!mounted) return
setInquiries(data as ListingInquiry[])
})
.catch((err: any) => {
if (!mounted) return
setError(err?.message || 'Failed to load inquiries')
})
.finally(() => {
if (!mounted) return
setLoading(false)
})
return () => { mounted = false }
}, [listing.id])
const formatOffer = (amount: number | null) => {
if (!amount) return null
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amount)
}
const updateInquiryStatus = async (inquiryId: number, status: 'new' | 'read' | 'replied' | 'spam') => {
setUpdatingId(inquiryId)
try {
const updated = await api.updateListingInquiry(listing.id, inquiryId, status) as ListingInquiry
setInquiries(prev => prev.map(i => i.id === inquiryId ? { ...i, ...updated } : i))
} catch (err: any) {
setError(err?.message || 'Failed to update inquiry')
} finally {
setUpdatingId(null)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={onClose} className="absolute inset-0 bg-black/70" aria-label="Close" />
<div className="relative w-full max-w-2xl border border-white/[0.10] bg-[#020202] shadow-2xl">
<div className="flex items-start justify-between gap-4 p-4 border-b border-white/[0.08]">
<div>
<div className="text-[10px] font-mono tracking-[0.2em] text-accent uppercase">Leads</div>
<h3 className="mt-1 text-lg font-display text-white">{listing.domain}</h3>
<p className="mt-1 text-xs font-mono text-white/40">
Buyer inquiries sent through Pounce.
</p>
</div>
<button type="button" onClick={onClose} className="p-1 text-white/40 hover:text-white" aria-label="Close">
<X className="w-5 h-5" />
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-6 h-6 text-accent animate-spin" />
</div>
) : error ? (
<div className="p-6 text-sm font-mono text-rose-400">{error}</div>
) : inquiries.length === 0 ? (
<div className="p-6 text-sm font-mono text-white/40">No inquiries yet.</div>
) : (
<div className="max-h-[70vh] overflow-auto">
<div className="space-y-px">
{inquiries.map((inq) => (
<div key={inq.id} className="p-4 border-b border-white/[0.06] bg-white/[0.02]">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-white">{inq.name}</span>
{inq.offer_amount ? (
<span className="px-2 py-0.5 text-[10px] font-mono uppercase border border-accent/20 bg-accent/10 text-accent">
Offer {formatOffer(inq.offer_amount)}
</span>
) : (
<span className="px-2 py-0.5 text-[10px] font-mono uppercase border border-white/[0.10] bg-white/[0.03] text-white/50">
Inquiry
</span>
)}
{!inq.read_at && (
<span className="px-2 py-0.5 text-[10px] font-mono uppercase border border-amber-400/20 bg-amber-400/10 text-amber-400">
New
</span>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] font-mono text-white/40">
<span className="flex items-center gap-1">
<Mail className="w-3.5 h-3.5" />
{inq.email}
</span>
{inq.phone && (
<span className="flex items-center gap-1">
<Phone className="w-3.5 h-3.5" />
{inq.phone}
</span>
)}
{inq.company && (
<span className="flex items-center gap-1">
<Building className="w-3.5 h-3.5" />
{inq.company}
</span>
)}
</div>
</div>
<div className="text-right text-[10px] font-mono text-white/30 shrink-0">
{new Date(inq.created_at).toLocaleString('en-US')}
</div>
</div>
<div className="mt-3 whitespace-pre-line text-sm text-white/70 leading-relaxed">
{inq.message}
</div>
<div className="mt-3 flex flex-wrap justify-end gap-2">
{!inq.read_at && (
<button
type="button"
onClick={() => updateInquiryStatus(inq.id, 'read')}
disabled={updatingId === inq.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.10] text-white/60 hover:text-white hover:bg-white/[0.06] text-[10px] font-mono uppercase tracking-wider transition-colors disabled:opacity-50"
title="Mark as read"
>
{updatingId === inq.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
Read
</button>
)}
<button
type="button"
onClick={() => updateInquiryStatus(inq.id, 'spam')}
disabled={updatingId === inq.id}
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.03] border border-white/[0.10] text-white/60 hover:text-rose-300 hover:border-rose-400/30 hover:bg-rose-400/10 text-[10px] font-mono uppercase tracking-wider transition-colors disabled:opacity-50"
title="Mark as spam"
>
{updatingId === inq.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <AlertCircle className="w-4 h-4" />}
Spam
</button>
<a
href={`mailto:${encodeURIComponent(inq.email)}?subject=${encodeURIComponent(`Re: ${listing.domain}`)}`}
onClick={() => updateInquiryStatus(inq.id, 'replied')}
className="inline-flex items-center gap-2 px-3 py-2 bg-white/[0.05] border border-white/[0.10] text-white/70 hover:text-white hover:bg-white/[0.08] text-[10px] font-mono uppercase tracking-wider transition-colors"
title="Reply via email"
>
Reply
<Send className="w-4 h-4" />
</a>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}
// ============================================================================
// CREATE LISTING WIZARD (3-Step Process)
// ============================================================================

View File

@ -381,6 +381,13 @@ class ApiClient {
return this.request<any[]>(`/listings/${id}/inquiries`)
}
async updateListingInquiry(listingId: number, inquiryId: number, status: 'new' | 'read' | 'replied' | 'spam') {
return this.request<any>(`/listings/${listingId}/inquiries/${inquiryId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
})
}
// Subscription
async getSubscription() {
return this.request<{