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
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:
230
UNICORN_PLAN.md
Normal file
230
UNICORN_PLAN.md
Normal 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) Deal‑System (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 2–3)
|
||||||
|
|
||||||
|
**Ziel**: Verhandlung im Produkt, nicht off-platform.
|
||||||
|
|
||||||
|
- **Threading**: Buyer ↔ Seller Messages als Conversation pro Listing
|
||||||
|
- **Notifications**: E‑Mail „New message“ + Login‑Gate
|
||||||
|
- **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 3–4)
|
||||||
|
|
||||||
|
**Ziel**: echte Conversion/GMV messbar machen.
|
||||||
|
|
||||||
|
- **“Mark as Sold”** auf Listing
|
||||||
|
- Gründe: sold on Pounce / sold off‑platform / 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 — Anti‑Abuse (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 2–4)
|
||||||
|
|
||||||
|
**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 2–3)
|
||||||
|
|
||||||
|
- 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 1–2)
|
||||||
|
|
||||||
|
- Templates skalieren (TLD/Intel/Price)
|
||||||
|
- klare CTA‑Pfade: „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 Conversion‑Engine
|
||||||
|
- “contact requires login” überall konsistent
|
||||||
|
|
||||||
|
**KPIs**
|
||||||
|
- view→login
|
||||||
|
- login→inquiry
|
||||||
|
|
||||||
|
### 3C — Viral Loop „Powered by Pounce“ (Monat 2–3)
|
||||||
|
|
||||||
|
- nur wenn intent passt / low intent fallback
|
||||||
|
- referral link + revenue share
|
||||||
|
|
||||||
|
**KPIs**
|
||||||
|
- referral signups
|
||||||
|
- CAC ~0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Skalierung / Telemetry
|
||||||
|
|
||||||
|
### 4A — Events (Woche 1–2)
|
||||||
|
|
||||||
|
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 1A–1C** (GMV & close-rate messbar)
|
||||||
|
2. **Yield 2A** (Connect Layer) parallel starten
|
||||||
|
3. **Events 4A** sofort mitziehen
|
||||||
|
4. **Yield 2B–2C** (Moat) sobald Connect stabil
|
||||||
|
5. Flywheel 3A–3C kontinuierlich
|
||||||
@ -188,6 +188,11 @@ class InquiryResponse(BaseModel):
|
|||||||
from_attributes = True
|
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):
|
class VerificationResponse(BaseModel):
|
||||||
"""DNS verification response."""
|
"""DNS verification response."""
|
||||||
verification_code: str
|
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)
|
@router.put("/{id}", response_model=ListingResponse)
|
||||||
async def update_listing(
|
async def update_listing(
|
||||||
id: int,
|
id: int,
|
||||||
|
|||||||
@ -124,13 +124,13 @@ async def get_yield_dashboard(
|
|||||||
domain_ids = [d.id for d in domains]
|
domain_ids = [d.id for d in domains]
|
||||||
monthly_result = await db.execute(
|
monthly_result = await db.execute(
|
||||||
select(
|
select(
|
||||||
func.count(YieldTransaction.id).label("count"),
|
func.count(YieldTransaction.id).label("count"),
|
||||||
func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("revenue"),
|
func.coalesce(func.sum(YieldTransaction.net_amount), 0).label("revenue"),
|
||||||
func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)).label("clicks"),
|
func.sum(case((YieldTransaction.event_type == "click", 1), else_=0)).label("clicks"),
|
||||||
func.sum(case((YieldTransaction.event_type.in_(["lead", "sale"]), 1), else_=0)).label("conversions"),
|
func.sum(case((YieldTransaction.event_type.in_(["lead", "sale"]), 1), else_=0)).label("conversions"),
|
||||||
).where(
|
).where(
|
||||||
YieldTransaction.yield_domain_id.in_(domain_ids),
|
YieldTransaction.yield_domain_id.in_(domain_ids),
|
||||||
YieldTransaction.created_at >= month_start,
|
YieldTransaction.created_at >= month_start,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
monthly_stats = monthly_result.first()
|
monthly_stats = monthly_result.first()
|
||||||
@ -153,8 +153,8 @@ async def get_yield_dashboard(
|
|||||||
pending_result = await db.execute(
|
pending_result = await db.execute(
|
||||||
select(func.coalesce(func.sum(YieldTransaction.net_amount), 0)).where(
|
select(func.coalesce(func.sum(YieldTransaction.net_amount), 0)).where(
|
||||||
YieldTransaction.yield_domain_id.in_(domain_ids),
|
YieldTransaction.yield_domain_id.in_(domain_ids),
|
||||||
YieldTransaction.status == "confirmed",
|
YieldTransaction.status == "confirmed",
|
||||||
YieldTransaction.paid_at.is_(None),
|
YieldTransaction.paid_at.is_(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
pending_payout = pending_result.scalar() or Decimal("0")
|
pending_payout = pending_result.scalar() or Decimal("0")
|
||||||
@ -258,8 +258,8 @@ async def get_yield_domain(
|
|||||||
"""
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(YieldDomain).where(
|
select(YieldDomain).where(
|
||||||
YieldDomain.id == domain_id,
|
YieldDomain.id == domain_id,
|
||||||
YieldDomain.user_id == current_user.id,
|
YieldDomain.user_id == current_user.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
domain = result.scalar_one_or_none()
|
domain = result.scalar_one_or_none()
|
||||||
@ -350,8 +350,8 @@ async def activate_domain_for_yield(
|
|||||||
if intent_result.suggested_partners:
|
if intent_result.suggested_partners:
|
||||||
partner_result = await db.execute(
|
partner_result = await db.execute(
|
||||||
select(AffiliatePartner).where(
|
select(AffiliatePartner).where(
|
||||||
AffiliatePartner.slug == intent_result.suggested_partners[0],
|
AffiliatePartner.slug == intent_result.suggested_partners[0],
|
||||||
AffiliatePartner.is_active == True,
|
AffiliatePartner.is_active == True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
partner = partner_result.scalar_one_or_none()
|
partner = partner_result.scalar_one_or_none()
|
||||||
@ -408,8 +408,8 @@ async def verify_domain_dns(
|
|||||||
"""
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(YieldDomain).where(
|
select(YieldDomain).where(
|
||||||
YieldDomain.id == domain_id,
|
YieldDomain.id == domain_id,
|
||||||
YieldDomain.user_id == current_user.id,
|
YieldDomain.user_id == current_user.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
domain = result.scalar_one_or_none()
|
domain = result.scalar_one_or_none()
|
||||||
@ -487,8 +487,8 @@ async def update_yield_domain(
|
|||||||
"""
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(YieldDomain).where(
|
select(YieldDomain).where(
|
||||||
YieldDomain.id == domain_id,
|
YieldDomain.id == domain_id,
|
||||||
YieldDomain.user_id == current_user.id,
|
YieldDomain.user_id == current_user.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
domain = result.scalar_one_or_none()
|
domain = result.scalar_one_or_none()
|
||||||
@ -501,8 +501,8 @@ async def update_yield_domain(
|
|||||||
# Validate partner exists
|
# Validate partner exists
|
||||||
partner_result = await db.execute(
|
partner_result = await db.execute(
|
||||||
select(AffiliatePartner).where(
|
select(AffiliatePartner).where(
|
||||||
AffiliatePartner.slug == update.active_route,
|
AffiliatePartner.slug == update.active_route,
|
||||||
AffiliatePartner.is_active == True,
|
AffiliatePartner.is_active == True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
partner = partner_result.scalar_one_or_none()
|
partner = partner_result.scalar_one_or_none()
|
||||||
@ -539,8 +539,8 @@ async def delete_yield_domain(
|
|||||||
"""
|
"""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(YieldDomain).where(
|
select(YieldDomain).where(
|
||||||
YieldDomain.id == domain_id,
|
YieldDomain.id == domain_id,
|
||||||
YieldDomain.user_id == current_user.id,
|
YieldDomain.user_id == current_user.id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
domain = result.scalar_one_or_none()
|
domain = result.scalar_one_or_none()
|
||||||
|
|||||||
@ -552,21 +552,21 @@ export default function AcquirePage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
key={`${auction.domain}-${i}`}
|
key={`${auction.domain}-${i}`}
|
||||||
href={auction.affiliate_url}
|
href={auction.affiliate_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"block p-3 border active:bg-white/[0.03] transition-all",
|
"block p-3 border active:bg-white/[0.03] transition-all",
|
||||||
"bg-[#0A0A0A] border-white/[0.08]"
|
"bg-[#0A0A0A] border-white/[0.08]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-bold text-white font-mono truncate">
|
<span className="text-sm font-bold text-white font-mono truncate">
|
||||||
{auction.domain}
|
{auction.domain}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-[10px] font-mono text-white/30">
|
<div className="flex items-center gap-2 mt-1 text-[10px] font-mono text-white/30">
|
||||||
@ -589,7 +589,7 @@ export default function AcquirePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -660,50 +660,50 @@ export default function AcquirePage() {
|
|||||||
{/* Search & Filters Bar */}
|
{/* Search & Filters Bar */}
|
||||||
<div className="mb-6 sticky top-20 z-30 backdrop-blur-xl bg-[#020202]/90 border-y border-white/[0.08] py-4 -mx-6 px-6">
|
<div className="mb-6 sticky top-20 z-30 backdrop-blur-xl bg-[#020202]/90 border-y border-white/[0.08] py-4 -mx-6 px-6">
|
||||||
<div className="flex gap-4 justify-between items-center">
|
<div className="flex gap-4 justify-between items-center">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative w-[400px] group">
|
<div className="relative w-[400px] group">
|
||||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 group-focus-within:text-accent transition-colors" />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 group-focus-within:text-accent transition-colors" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search assets..."
|
placeholder="Search assets..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full pl-12 pr-4 py-3 bg-[#0A0A0A] border border-white/10 text-white placeholder:text-white/20 font-mono text-sm focus:outline-none focus:border-accent transition-all"
|
className="w-full pl-12 pr-4 py-3 bg-[#0A0A0A] border border-white/10 text-white placeholder:text-white/20 font-mono text-sm focus:outline-none focus:border-accent transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select
|
<select
|
||||||
value={selectedPlatform}
|
value={selectedPlatform}
|
||||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||||
className="appearance-none px-4 py-3 bg-[#0A0A0A] border border-white/10 text-white font-mono text-sm focus:outline-none focus:border-accent cursor-pointer"
|
className="appearance-none px-4 py-3 bg-[#0A0A0A] border border-white/10 text-white font-mono text-sm focus:outline-none focus:border-accent cursor-pointer"
|
||||||
>
|
>
|
||||||
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{PLATFORMS.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div className="flex border border-white/10 bg-[#0A0A0A]">
|
<div className="flex border border-white/10 bg-[#0A0A0A]">
|
||||||
{[
|
{[
|
||||||
{ id: 'all' as const, label: 'All', icon: Gavel },
|
{ id: 'all' as const, label: 'All', icon: Gavel },
|
||||||
{ id: 'ending' as const, label: 'Ending', icon: Timer },
|
{ id: 'ending' as const, label: 'Ending', icon: Timer },
|
||||||
{ id: 'hot' as const, label: 'Hot', icon: Flame },
|
{ id: 'hot' as const, label: 'Hot', icon: Flame },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"px-5 py-3 flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-all border-r border-white/10 last:border-r-0",
|
"px-5 py-3 flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-all border-r border-white/10 last:border-r-0",
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? "bg-white/10 text-white border-b-2 border-accent"
|
? "bg-white/10 text-white border-b-2 border-accent"
|
||||||
: "text-white/40 hover:text-white border-b-2 border-transparent"
|
: "text-white/40 hover:text-white border-b-2 border-transparent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<tab.icon className={clsx("w-4 h-4", activeTab === tab.id ? "text-accent" : "")} />
|
<tab.icon className={clsx("w-4 h-4", activeTab === tab.id ? "text-accent" : "")} />
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -719,18 +719,18 @@ export default function AcquirePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Body */}
|
{/* Table Body */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : filteredAuctions.length === 0 ? (
|
) : filteredAuctions.length === 0 ? (
|
||||||
<div className="text-center py-20 text-white/30 font-mono">No assets found</div>
|
<div className="text-center py-20 text-white/30 font-mono">No assets found</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
{filteredAuctions.map((auction, i) => (
|
{filteredAuctions.map((auction, i) => (
|
||||||
auction.is_pounce ? (
|
auction.is_pounce ? (
|
||||||
<button
|
<button
|
||||||
key={`${auction.domain}-${i}`}
|
key={`${auction.domain}-${i}`}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handlePounceDirectClick(auction)}
|
onClick={() => handlePounceDirectClick(auction)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@ -744,7 +744,7 @@ export default function AcquirePage() {
|
|||||||
<span className="text-[8px] font-bold text-accent uppercase">Direct</span>
|
<span className="text-[8px] font-bold text-accent uppercase">Direct</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono text-base text-white group-hover:text-accent transition-colors truncate">
|
<span className="font-mono text-base text-white group-hover:text-accent transition-colors truncate">
|
||||||
{auction.domain}
|
{auction.domain}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center text-xs font-mono text-white/40 uppercase">
|
<div className="text-center text-xs font-mono text-white/40 uppercase">
|
||||||
@ -798,14 +798,14 @@ export default function AcquirePage() {
|
|||||||
"w-8 h-8 border flex items-center justify-center transition-all",
|
"w-8 h-8 border flex items-center justify-center transition-all",
|
||||||
"border-white/10 text-white/30 group-hover:bg-white group-hover:text-black"
|
"border-white/10 text-white/30 group-hover:bg-white group-hover:text-black"
|
||||||
)}>
|
)}>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
|
</a>
|
||||||
)
|
)
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Footer */}
|
{/* Stats Footer */}
|
||||||
@ -822,18 +822,18 @@ export default function AcquirePage() {
|
|||||||
<div className="bg-[#080808] p-10 text-left">
|
<div className="bg-[#080808] p-10 text-left">
|
||||||
<div className="inline-flex items-center justify-center w-10 h-10 border border-accent/20 bg-accent/5 text-accent mb-4">
|
<div className="inline-flex items-center justify-center w-10 h-10 border border-accent/20 bg-accent/5 text-accent mb-4">
|
||||||
<Filter className="w-5 h-5" />
|
<Filter className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-display text-white mb-2">Eliminate Noise.</h3>
|
<h3 className="text-xl font-display text-white mb-2">Eliminate Noise.</h3>
|
||||||
<p className="text-white/50 mb-6 max-w-md text-sm">
|
<p className="text-white/50 mb-6 max-w-md text-sm">
|
||||||
Our Trader plan filters 99% of junk domains automatically.
|
Our Trader plan filters 99% of junk domains automatically.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-black text-xs font-bold uppercase tracking-widest hover:bg-white transition-all"
|
||||||
>
|
>
|
||||||
Upgrade <TrendingUp className="w-4 h-4" />
|
Upgrade <TrendingUp className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -382,36 +382,36 @@ export default function BuyDomainPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-in">
|
<form onSubmit={handleSubmit} className="space-y-4 animate-fade-in">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-bold text-white text-lg">
|
<h3 className="font-bold text-white text-lg">
|
||||||
{listing.asking_price ? 'Purchase Inquiry' : 'Contact Seller'}
|
{listing.asking_price ? 'Purchase Inquiry' : 'Contact Seller'}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
|
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email"
|
placeholder="Email"
|
||||||
required
|
required
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
readOnly={isAuthenticated}
|
readOnly={isAuthenticated}
|
||||||
title={isAuthenticated ? 'Uses your account email' : undefined}
|
title={isAuthenticated ? 'Uses your account email' : undefined}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all",
|
"w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all",
|
||||||
isAuthenticated && "opacity-70 cursor-not-allowed"
|
isAuthenticated && "opacity-70 cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<p className="text-[10px] text-zinc-600 font-mono">
|
<p className="text-[10px] text-zinc-600 font-mono">
|
||||||
@ -419,13 +419,13 @@ export default function BuyDomainPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Phone (Optional)"
|
placeholder="Phone (Optional)"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
|
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all"
|
||||||
/>
|
/>
|
||||||
{listing.allow_offers && (
|
{listing.allow_offers && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">$</span>
|
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">$</span>
|
||||||
<input
|
<input
|
||||||
@ -438,28 +438,28 @@ export default function BuyDomainPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="I'm interested in this domain..."
|
placeholder="I'm interested in this domain..."
|
||||||
rows={3}
|
rows={3}
|
||||||
required
|
required
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||||
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all resize-none"
|
className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="w-full py-4 bg-white text-black font-bold text-lg rounded-xl hover:bg-zinc-200 transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-6 shadow-lg shadow-white/10"
|
className="w-full py-4 bg-white text-black font-bold text-lg rounded-xl hover:bg-zinc-200 transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-6 shadow-lg shadow-white/10"
|
||||||
>
|
>
|
||||||
{submitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
{submitting ? <Loader2 className="w-5 h-5 animate-spin" /> : <Send className="w-5 h-5" />}
|
||||||
{listing.asking_price ? 'Send Purchase Request' : 'Send Offer'}
|
{listing.asking_price ? 'Send Purchase Request' : 'Send Offer'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-center text-xs text-zinc-600 mt-3">
|
<p className="text-center text-xs text-zinc-600 mt-3">
|
||||||
Secure escrow transfer available via Escrow.com
|
Secure escrow transfer available via Escrow.com
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -307,9 +307,9 @@ export default function BrowseListingsPage() {
|
|||||||
<div className="mt-4 pt-4 border-t border-border/50 flex items-center justify-between">
|
<div className="mt-4 pt-4 border-t border-border/50 flex items-center justify-between">
|
||||||
<div className="text-sm text-foreground-muted">
|
<div className="text-sm text-foreground-muted">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Eye className="w-3 h-3" />
|
<Eye className="w-3 h-3" />
|
||||||
View Details
|
View Details
|
||||||
</span>
|
</span>
|
||||||
{!isAuthenticated && (
|
{!isAuthenticated && (
|
||||||
<span className="mt-1 flex items-center gap-1 text-xs text-foreground-subtle">
|
<span className="mt-1 flex items-center gap-1 text-xs text-foreground-subtle">
|
||||||
<Lock className="w-3 h-3" />
|
<Lock className="w-3 h-3" />
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
Plus, Shield, Eye, ExternalLink, Loader2, Trash2,
|
Plus, Shield, Eye, ExternalLink, Loader2, Trash2,
|
||||||
CheckCircle, AlertCircle, Copy, X, Tag, Sparkles,
|
CheckCircle, AlertCircle, Copy, X, Tag, Sparkles,
|
||||||
TrendingUp, Gavel, Target, Menu, Settings, LogOut, Crown, Zap, Coins,
|
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'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
@ -39,6 +39,19 @@ interface Listing {
|
|||||||
created_at: string
|
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
|
// MAIN PAGE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -52,6 +65,7 @@ export default function MyListingsPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateWizard, setShowCreateWizard] = useState(false)
|
const [showCreateWizard, setShowCreateWizard] = useState(false)
|
||||||
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
|
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
|
||||||
|
const [leadsListing, setLeadsListing] = useState<Listing | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
@ -321,6 +335,7 @@ export default function MyListingsPage() {
|
|||||||
onDelete={() => handleDelete(listing.id, listing.domain)}
|
onDelete={() => handleDelete(listing.id, listing.domain)}
|
||||||
onVerify={() => setSelectedListing(listing)}
|
onVerify={() => setSelectedListing(listing)}
|
||||||
onPublish={() => handlePublish(listing)}
|
onPublish={() => handlePublish(listing)}
|
||||||
|
onLeads={() => setLeadsListing(listing)}
|
||||||
isDeleting={deletingId === listing.id}
|
isDeleting={deletingId === listing.id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -335,9 +350,9 @@ export default function MyListingsPage() {
|
|||||||
{tier === 'trader' ? 'Upgrade to Tycoon for 50 listings' : 'Contact us for enterprise plans'}
|
{tier === 'trader' ? 'Upgrade to Tycoon for 50 listings' : 'Contact us for enterprise plans'}
|
||||||
</p>
|
</p>
|
||||||
{tier === 'trader' && (
|
{tier === 'trader' && (
|
||||||
<Link href="/pricing" className="inline-flex items-center gap-2 px-4 py-2 bg-amber-400 text-black text-xs font-bold uppercase tracking-wider">
|
<Link href="/pricing" className="inline-flex items-center gap-2 px-4 py-2 bg-amber-400 text-black text-xs font-bold uppercase tracking-wider">
|
||||||
<Sparkles className="w-3 h-3" />Upgrade to Tycoon
|
<Sparkles className="w-3 h-3" />Upgrade to Tycoon
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -389,6 +404,14 @@ export default function MyListingsPage() {
|
|||||||
onVerified={() => { loadListings(); setSelectedListing(null) }}
|
onVerified={() => { loadListings(); setSelectedListing(null) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* LEADS MODAL */}
|
||||||
|
{leadsListing && (
|
||||||
|
<LeadsModal
|
||||||
|
listing={leadsListing}
|
||||||
|
onClose={() => setLeadsListing(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -403,6 +426,7 @@ function ListingRow({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onVerify,
|
onVerify,
|
||||||
onPublish,
|
onPublish,
|
||||||
|
onLeads,
|
||||||
isDeleting
|
isDeleting
|
||||||
}: {
|
}: {
|
||||||
listing: Listing
|
listing: Listing
|
||||||
@ -410,6 +434,7 @@ function ListingRow({
|
|||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onVerify: () => void
|
onVerify: () => void
|
||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
|
onLeads: () => void
|
||||||
isDeleting: boolean
|
isDeleting: boolean
|
||||||
}) {
|
}) {
|
||||||
const isDraft = listing.status === 'draft'
|
const isDraft = listing.status === 'draft'
|
||||||
@ -458,6 +483,16 @@ function ListingRow({
|
|||||||
<ExternalLink className="w-3 h-3" />View
|
<ExternalLink className="w-3 h-3" />View
|
||||||
</a>
|
</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}
|
<button onClick={onDelete} disabled={isDeleting}
|
||||||
className="px-4 py-2 border border-white/[0.08] text-white/40 hover:text-rose-400">
|
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" />}
|
{isDeleting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
@ -467,7 +502,7 @@ function ListingRow({
|
|||||||
|
|
||||||
{/* Desktop */}
|
{/* Desktop */}
|
||||||
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_60px_60px_120px] gap-4 items-center px-3 py-3">
|
<div className="hidden lg:grid grid-cols-[1fr_100px_100px_60px_60px_120px] gap-4 items-center px-3 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={clsx("w-8 h-8 border flex items-center justify-center",
|
<div className={clsx("w-8 h-8 border flex items-center justify-center",
|
||||||
listing.is_verified ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]"
|
listing.is_verified ? "bg-accent/10 border-accent/20" : "bg-white/[0.02] border-white/[0.06]"
|
||||||
)}>
|
)}>
|
||||||
@ -505,6 +540,16 @@ function ListingRow({
|
|||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
</a>
|
</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}
|
<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">
|
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" />}
|
{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)
|
// CREATE LISTING WIZARD (3-Step Process)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -817,7 +1029,7 @@ function CreateListingWizard({ onClose, onSuccess, prefillDomain }: {
|
|||||||
<CheckCircle className="w-8 h-8 text-accent" />
|
<CheckCircle className="w-8 h-8 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-display text-white mb-2">Ownership Verified!</h2>
|
<h2 className="text-xl font-display text-white mb-2">Ownership Verified!</h2>
|
||||||
<p className="text-xs font-mono text-white/40">Step 3 of 3: Publish your listing to the Pounce Market</p>
|
<p className="text-xs font-mono text-white/40">Step 3 of 3: Publish your listing to the Pounce Market</p>
|
||||||
</div>
|
</div>
|
||||||
@ -941,7 +1153,7 @@ function DnsVerificationModal({ listing, onClose, onVerified }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">TXT Value</label>
|
<label className="block text-[10px] font-mono text-white/40 uppercase mb-2">TXT Value</label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 px-4 py-3 bg-white/5 border border-white/10 text-accent text-sm font-mono overflow-x-auto">
|
<div className="flex-1 px-4 py-3 bg-white/5 border border-white/10 text-accent text-sm font-mono overflow-x-auto">
|
||||||
|
|||||||
@ -381,6 +381,13 @@ class ApiClient {
|
|||||||
return this.request<any[]>(`/listings/${id}/inquiries`)
|
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
|
// Subscription
|
||||||
async getSubscription() {
|
async getSubscription() {
|
||||||
return this.request<{
|
return this.request<{
|
||||||
|
|||||||
Reference in New Issue
Block a user