feat: Add For Sale Marketplace + Sniper Alerts
BACKEND - New Models:
- DomainListing: For sale landing pages with DNS verification
- ListingInquiry: Contact form submissions from buyers
- ListingView: Analytics tracking
- SniperAlert: Hyper-personalized auction filters
- SniperAlertMatch: Matched auctions for alerts
BACKEND - New APIs:
- /listings: Browse, create, manage domain listings
- /listings/{slug}/inquire: Buyer contact form
- /listings/{id}/verify-dns: DNS ownership verification
- /sniper-alerts: Create, manage, test alert filters
FRONTEND - New Pages:
- /buy: Public marketplace browse page
- /buy/[slug]: Individual listing page with contact form
- /command/listings: Manage your listings
- /command/alerts: Sniper alerts dashboard
FRONTEND - Updated:
- Sidebar: Added For Sale + Sniper Alerts nav items
- Landing page: New features teaser section
DOCS:
- DATABASE_MIGRATIONS.md: Complete SQL for new tables
From analysis_3.md:
- Strategie 2: Micro-Marktplatz (For Sale Pages)
- Strategie 4: Alerts nach Maß (Sniper Alerts)
- Säule 2: DNS Ownership Verification
This commit is contained in:
246
DATABASE_MIGRATIONS.md
Normal file
246
DATABASE_MIGRATIONS.md
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
# Database Migrations Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document lists all database tables that need to be created when deploying Pounce to a new server.
|
||||||
|
|
||||||
|
## Required Tables
|
||||||
|
|
||||||
|
### Core Tables (Already Implemented)
|
||||||
|
|
||||||
|
| Table | Model | Description |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| `users` | User | User accounts and authentication |
|
||||||
|
| `subscriptions` | Subscription | User subscription plans (Scout, Trader, Tycoon) |
|
||||||
|
| `domains` | Domain | Tracked domains in watchlists |
|
||||||
|
| `domain_checks` | DomainCheck | Domain availability check history |
|
||||||
|
| `tld_prices` | TLDPrice | TLD price history |
|
||||||
|
| `tld_info` | TLDInfo | TLD metadata |
|
||||||
|
| `portfolio_domains` | PortfolioDomain | User-owned domains |
|
||||||
|
| `domain_valuations` | DomainValuation | Domain valuation history |
|
||||||
|
| `domain_auctions` | DomainAuction | Scraped auction listings |
|
||||||
|
| `auction_scrape_logs` | AuctionScrapeLog | Scraping job logs |
|
||||||
|
| `newsletter_subscribers` | NewsletterSubscriber | Email newsletter list |
|
||||||
|
| `price_alerts` | PriceAlert | TLD price change alerts |
|
||||||
|
| `admin_activity_logs` | AdminActivityLog | Admin action audit log |
|
||||||
|
| `blog_posts` | BlogPost | Blog content |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### NEW Tables (To Be Created)
|
||||||
|
|
||||||
|
These tables were added for the **"For Sale" Marketplace** and **Sniper Alerts** features:
|
||||||
|
|
||||||
|
#### 1. Domain Listings (For Sale Marketplace)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Main listing table
|
||||||
|
CREATE TABLE domain_listings (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
domain VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
slug VARCHAR(300) NOT NULL UNIQUE,
|
||||||
|
title VARCHAR(200),
|
||||||
|
description TEXT,
|
||||||
|
asking_price FLOAT,
|
||||||
|
min_offer FLOAT,
|
||||||
|
currency VARCHAR(3) DEFAULT 'USD',
|
||||||
|
price_type VARCHAR(20) DEFAULT 'fixed',
|
||||||
|
pounce_score INTEGER,
|
||||||
|
estimated_value FLOAT,
|
||||||
|
verification_status VARCHAR(20) DEFAULT 'not_started',
|
||||||
|
verification_code VARCHAR(64),
|
||||||
|
verified_at TIMESTAMP,
|
||||||
|
status VARCHAR(30) DEFAULT 'draft',
|
||||||
|
show_valuation BOOLEAN DEFAULT TRUE,
|
||||||
|
allow_offers BOOLEAN DEFAULT TRUE,
|
||||||
|
featured BOOLEAN DEFAULT FALSE,
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
|
inquiry_count INTEGER DEFAULT 0,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
published_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_listings_user_id ON domain_listings(user_id);
|
||||||
|
CREATE INDEX idx_listings_domain ON domain_listings(domain);
|
||||||
|
CREATE INDEX idx_listings_slug ON domain_listings(slug);
|
||||||
|
CREATE INDEX idx_listings_status ON domain_listings(status);
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Contact inquiries from potential buyers
|
||||||
|
CREATE TABLE listing_inquiries (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
listing_id INTEGER NOT NULL REFERENCES domain_listings(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
company VARCHAR(200),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
offer_amount FLOAT,
|
||||||
|
status VARCHAR(20) DEFAULT 'new',
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
read_at TIMESTAMP,
|
||||||
|
replied_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_inquiries_listing_id ON listing_inquiries(listing_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Analytics: page views
|
||||||
|
CREATE TABLE listing_views (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
listing_id INTEGER NOT NULL REFERENCES domain_listings(id) ON DELETE CASCADE,
|
||||||
|
ip_address VARCHAR(45),
|
||||||
|
user_agent VARCHAR(500),
|
||||||
|
referrer VARCHAR(500),
|
||||||
|
user_id INTEGER REFERENCES users(id),
|
||||||
|
viewed_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_views_listing_id ON listing_views(listing_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Sniper Alerts
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Saved filter configurations for personalized alerts
|
||||||
|
CREATE TABLE sniper_alerts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description VARCHAR(500),
|
||||||
|
filter_criteria JSONB NOT NULL DEFAULT '{}',
|
||||||
|
tlds VARCHAR(500),
|
||||||
|
keywords VARCHAR(500),
|
||||||
|
exclude_keywords VARCHAR(500),
|
||||||
|
max_length INTEGER,
|
||||||
|
min_length INTEGER,
|
||||||
|
max_price FLOAT,
|
||||||
|
min_price FLOAT,
|
||||||
|
max_bids INTEGER,
|
||||||
|
ending_within_hours INTEGER,
|
||||||
|
platforms VARCHAR(200),
|
||||||
|
no_numbers BOOLEAN DEFAULT FALSE,
|
||||||
|
no_hyphens BOOLEAN DEFAULT FALSE,
|
||||||
|
exclude_chars VARCHAR(50),
|
||||||
|
notify_email BOOLEAN DEFAULT TRUE,
|
||||||
|
notify_sms BOOLEAN DEFAULT FALSE,
|
||||||
|
notify_push BOOLEAN DEFAULT FALSE,
|
||||||
|
max_notifications_per_day INTEGER DEFAULT 10,
|
||||||
|
cooldown_minutes INTEGER DEFAULT 30,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
matches_count INTEGER DEFAULT 0,
|
||||||
|
notifications_sent INTEGER DEFAULT 0,
|
||||||
|
last_matched_at TIMESTAMP,
|
||||||
|
last_notified_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_alerts_user_id ON sniper_alerts(user_id);
|
||||||
|
CREATE INDEX idx_alerts_active ON sniper_alerts(is_active);
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Matched auctions for each alert
|
||||||
|
CREATE TABLE sniper_alert_matches (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
alert_id INTEGER NOT NULL REFERENCES sniper_alerts(id) ON DELETE CASCADE,
|
||||||
|
domain VARCHAR(255) NOT NULL,
|
||||||
|
platform VARCHAR(50) NOT NULL,
|
||||||
|
current_bid FLOAT NOT NULL,
|
||||||
|
end_time TIMESTAMP NOT NULL,
|
||||||
|
auction_url VARCHAR(500),
|
||||||
|
notified BOOLEAN DEFAULT FALSE,
|
||||||
|
clicked BOOLEAN DEFAULT FALSE,
|
||||||
|
matched_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
notified_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_matches_alert_id ON sniper_alert_matches(alert_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Commands
|
||||||
|
|
||||||
|
### Using Alembic (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Generate migration
|
||||||
|
alembic revision --autogenerate -m "Add marketplace and sniper alert tables"
|
||||||
|
|
||||||
|
# Apply migration
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual SQL (Alternative)
|
||||||
|
|
||||||
|
Run the SQL statements above in order on your PostgreSQL database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After running migrations, verify tables exist:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT table_name
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN (
|
||||||
|
'domain_listings',
|
||||||
|
'listing_inquiries',
|
||||||
|
'listing_views',
|
||||||
|
'sniper_alerts',
|
||||||
|
'sniper_alert_matches'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature References
|
||||||
|
|
||||||
|
These tables implement features from:
|
||||||
|
|
||||||
|
- **analysis_3.md** - "Micro-Marktplatz" (For Sale Landing Pages)
|
||||||
|
- **analysis_3.md** - "Sniper Alerts" (Strategie 4: Alerts nach Maß)
|
||||||
|
- **concept.md** - "For Sale Pages" marketplace feature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Listings (For Sale)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/listings` | Browse public listings |
|
||||||
|
| GET | `/api/v1/listings/{slug}` | View listing details |
|
||||||
|
| POST | `/api/v1/listings/{slug}/inquire` | Contact seller |
|
||||||
|
| POST | `/api/v1/listings` | Create listing (auth) |
|
||||||
|
| GET | `/api/v1/listings/my` | Get my listings (auth) |
|
||||||
|
| PUT | `/api/v1/listings/{id}` | Update listing (auth) |
|
||||||
|
| DELETE | `/api/v1/listings/{id}` | Delete listing (auth) |
|
||||||
|
| POST | `/api/v1/listings/{id}/verify-dns` | Start verification |
|
||||||
|
| GET | `/api/v1/listings/{id}/verify-dns/check` | Check verification |
|
||||||
|
|
||||||
|
### Sniper Alerts
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/sniper-alerts` | Get my alerts |
|
||||||
|
| POST | `/api/v1/sniper-alerts` | Create alert |
|
||||||
|
| PUT | `/api/v1/sniper-alerts/{id}` | Update alert |
|
||||||
|
| DELETE | `/api/v1/sniper-alerts/{id}` | Delete alert |
|
||||||
|
| GET | `/api/v1/sniper-alerts/{id}/matches` | Get matched auctions |
|
||||||
|
| POST | `/api/v1/sniper-alerts/{id}/test` | Test alert criteria |
|
||||||
|
|
||||||
@ -14,6 +14,8 @@ from app.api.webhooks import router as webhooks_router
|
|||||||
from app.api.contact import router as contact_router
|
from app.api.contact import router as contact_router
|
||||||
from app.api.price_alerts import router as price_alerts_router
|
from app.api.price_alerts import router as price_alerts_router
|
||||||
from app.api.blog import router as blog_router
|
from app.api.blog import router as blog_router
|
||||||
|
from app.api.listings import router as listings_router
|
||||||
|
from app.api.sniper_alerts import router as sniper_alerts_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@ -28,6 +30,12 @@ api_router.include_router(price_alerts_router, prefix="/price-alerts", tags=["Pr
|
|||||||
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
|
api_router.include_router(portfolio_router, prefix="/portfolio", tags=["Portfolio"])
|
||||||
api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"])
|
api_router.include_router(auctions_router, prefix="/auctions", tags=["Smart Pounce - Auctions"])
|
||||||
|
|
||||||
|
# Marketplace (For Sale) - from analysis_3.md
|
||||||
|
api_router.include_router(listings_router, prefix="/listings", tags=["Marketplace - For Sale"])
|
||||||
|
|
||||||
|
# Sniper Alerts - from analysis_3.md
|
||||||
|
api_router.include_router(sniper_alerts_router, prefix="/sniper-alerts", tags=["Sniper Alerts"])
|
||||||
|
|
||||||
# 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"])
|
||||||
|
|
||||||
|
|||||||
814
backend/app/api/listings.py
Normal file
814
backend/app/api/listings.py
Normal file
@ -0,0 +1,814 @@
|
|||||||
|
"""
|
||||||
|
Domain Listings API - Pounce Marketplace
|
||||||
|
|
||||||
|
This implements the "Micro-Marktplatz" from analysis_3.md:
|
||||||
|
- Create professional "For Sale" landing pages
|
||||||
|
- DNS verification for ownership
|
||||||
|
- Contact form for buyers
|
||||||
|
- Analytics
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /listings - Public: Browse active listings
|
||||||
|
- GET /listings/{slug} - Public: View listing details
|
||||||
|
- POST /listings/{slug}/inquire - Public: Contact seller
|
||||||
|
- POST /listings - Auth: Create new listing
|
||||||
|
- GET /listings/my - Auth: Get user's listings
|
||||||
|
- PUT /listings/{id} - Auth: Update listing
|
||||||
|
- DELETE /listings/{id} - Auth: Delete listing
|
||||||
|
- POST /listings/{id}/verify-dns - Auth: Start DNS verification
|
||||||
|
- GET /listings/{id}/verify-dns/check - Auth: Check verification status
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field, EmailStr
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.api.deps import get_current_user, get_current_user_optional
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.listing import DomainListing, ListingInquiry, ListingView, ListingStatus, VerificationStatus
|
||||||
|
from app.services.valuation import valuation_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Schemas ==============
|
||||||
|
|
||||||
|
class ListingCreate(BaseModel):
|
||||||
|
"""Create a new domain listing."""
|
||||||
|
domain: str = Field(..., min_length=3, max_length=255)
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
asking_price: Optional[float] = Field(None, ge=0)
|
||||||
|
min_offer: Optional[float] = Field(None, ge=0)
|
||||||
|
currency: str = Field("USD", max_length=3)
|
||||||
|
price_type: str = Field("negotiable") # fixed, negotiable, make_offer
|
||||||
|
show_valuation: bool = True
|
||||||
|
allow_offers: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ListingUpdate(BaseModel):
|
||||||
|
"""Update a listing."""
|
||||||
|
title: Optional[str] = Field(None, max_length=200)
|
||||||
|
description: Optional[str] = None
|
||||||
|
asking_price: Optional[float] = Field(None, ge=0)
|
||||||
|
min_offer: Optional[float] = Field(None, ge=0)
|
||||||
|
price_type: Optional[str] = None
|
||||||
|
show_valuation: Optional[bool] = None
|
||||||
|
allow_offers: Optional[bool] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ListingResponse(BaseModel):
|
||||||
|
"""Listing response."""
|
||||||
|
id: int
|
||||||
|
domain: str
|
||||||
|
slug: str
|
||||||
|
title: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
|
asking_price: Optional[float]
|
||||||
|
min_offer: Optional[float]
|
||||||
|
currency: str
|
||||||
|
price_type: str
|
||||||
|
pounce_score: Optional[int]
|
||||||
|
estimated_value: Optional[float]
|
||||||
|
verification_status: str
|
||||||
|
is_verified: bool
|
||||||
|
status: str
|
||||||
|
show_valuation: bool
|
||||||
|
allow_offers: bool
|
||||||
|
view_count: int
|
||||||
|
inquiry_count: int
|
||||||
|
public_url: str
|
||||||
|
created_at: datetime
|
||||||
|
published_at: Optional[datetime]
|
||||||
|
|
||||||
|
# Seller info (minimal for privacy)
|
||||||
|
seller_verified: bool = False
|
||||||
|
seller_member_since: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ListingPublicResponse(BaseModel):
|
||||||
|
"""Public listing response (limited info)."""
|
||||||
|
domain: str
|
||||||
|
slug: str
|
||||||
|
title: Optional[str]
|
||||||
|
description: Optional[str]
|
||||||
|
asking_price: Optional[float]
|
||||||
|
currency: str
|
||||||
|
price_type: str
|
||||||
|
pounce_score: Optional[int]
|
||||||
|
estimated_value: Optional[float]
|
||||||
|
is_verified: bool
|
||||||
|
allow_offers: bool
|
||||||
|
public_url: str
|
||||||
|
|
||||||
|
# Seller trust indicators
|
||||||
|
seller_verified: bool
|
||||||
|
seller_member_since: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class InquiryCreate(BaseModel):
|
||||||
|
"""Create an inquiry for a listing."""
|
||||||
|
name: str = Field(..., min_length=2, max_length=100)
|
||||||
|
email: EmailStr
|
||||||
|
phone: Optional[str] = Field(None, max_length=50)
|
||||||
|
company: Optional[str] = Field(None, max_length=200)
|
||||||
|
message: str = Field(..., min_length=10, max_length=2000)
|
||||||
|
offer_amount: Optional[float] = Field(None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class InquiryResponse(BaseModel):
|
||||||
|
"""Inquiry response for listing owner."""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
phone: Optional[str]
|
||||||
|
company: Optional[str]
|
||||||
|
message: str
|
||||||
|
offer_amount: Optional[float]
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
read_at: Optional[datetime]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationResponse(BaseModel):
|
||||||
|
"""DNS verification response."""
|
||||||
|
verification_code: str
|
||||||
|
dns_record_type: str = "TXT"
|
||||||
|
dns_record_name: str
|
||||||
|
dns_record_value: str
|
||||||
|
instructions: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Helper Functions ==============
|
||||||
|
|
||||||
|
def _generate_slug(domain: str) -> str:
|
||||||
|
"""Generate URL-friendly slug from domain."""
|
||||||
|
# Remove TLD for cleaner slug
|
||||||
|
slug = domain.lower().replace('.', '-')
|
||||||
|
# Remove any non-alphanumeric chars except hyphens
|
||||||
|
slug = re.sub(r'[^a-z0-9-]', '', slug)
|
||||||
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_verification_code() -> str:
|
||||||
|
"""Generate a unique verification code."""
|
||||||
|
return f"pounce-verify-{secrets.token_hex(16)}"
|
||||||
|
|
||||||
|
|
||||||
|
# Security: Block phishing keywords (from analysis_3.md - Säule 3)
|
||||||
|
BLOCKED_KEYWORDS = [
|
||||||
|
'login', 'bank', 'verify', 'paypal', 'password', 'account',
|
||||||
|
'credit', 'social security', 'ssn', 'wire', 'transfer'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _check_content_safety(text: str) -> bool:
|
||||||
|
"""Check if content contains phishing keywords."""
|
||||||
|
text_lower = text.lower()
|
||||||
|
return not any(keyword in text_lower for keyword in BLOCKED_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Public Endpoints ==============
|
||||||
|
|
||||||
|
@router.get("", response_model=List[ListingPublicResponse])
|
||||||
|
async def browse_listings(
|
||||||
|
keyword: Optional[str] = Query(None),
|
||||||
|
min_price: Optional[float] = Query(None, ge=0),
|
||||||
|
max_price: Optional[float] = Query(None, ge=0),
|
||||||
|
verified_only: bool = Query(False),
|
||||||
|
sort_by: str = Query("newest", enum=["newest", "price_asc", "price_desc", "popular"]),
|
||||||
|
limit: int = Query(20, le=50),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Browse active domain listings (public)."""
|
||||||
|
query = select(DomainListing).where(
|
||||||
|
DomainListing.status == ListingStatus.ACTIVE.value
|
||||||
|
)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
query = query.where(DomainListing.domain.ilike(f"%{keyword}%"))
|
||||||
|
|
||||||
|
if min_price is not None:
|
||||||
|
query = query.where(DomainListing.asking_price >= min_price)
|
||||||
|
|
||||||
|
if max_price is not None:
|
||||||
|
query = query.where(DomainListing.asking_price <= max_price)
|
||||||
|
|
||||||
|
if verified_only:
|
||||||
|
query = query.where(
|
||||||
|
DomainListing.verification_status == VerificationStatus.VERIFIED.value
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sorting
|
||||||
|
if sort_by == "price_asc":
|
||||||
|
query = query.order_by(DomainListing.asking_price.asc().nullslast())
|
||||||
|
elif sort_by == "price_desc":
|
||||||
|
query = query.order_by(DomainListing.asking_price.desc().nullsfirst())
|
||||||
|
elif sort_by == "popular":
|
||||||
|
query = query.order_by(DomainListing.view_count.desc())
|
||||||
|
else: # newest
|
||||||
|
query = query.order_by(DomainListing.published_at.desc())
|
||||||
|
|
||||||
|
query = query.offset(offset).limit(limit)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
listings = list(result.scalars().all())
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for listing in listings:
|
||||||
|
responses.append(ListingPublicResponse(
|
||||||
|
domain=listing.domain,
|
||||||
|
slug=listing.slug,
|
||||||
|
title=listing.title,
|
||||||
|
description=listing.description,
|
||||||
|
asking_price=listing.asking_price,
|
||||||
|
currency=listing.currency,
|
||||||
|
price_type=listing.price_type,
|
||||||
|
pounce_score=listing.pounce_score if listing.show_valuation else None,
|
||||||
|
estimated_value=listing.estimated_value if listing.show_valuation else None,
|
||||||
|
is_verified=listing.is_verified,
|
||||||
|
allow_offers=listing.allow_offers,
|
||||||
|
public_url=listing.public_url,
|
||||||
|
seller_verified=listing.is_verified,
|
||||||
|
seller_member_since=listing.user.created_at if listing.user else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
return responses
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}", response_model=ListingPublicResponse)
|
||||||
|
async def get_listing_by_slug(
|
||||||
|
slug: str,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Optional[User] = Depends(get_current_user_optional),
|
||||||
|
):
|
||||||
|
"""Get listing details by slug (public)."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing).where(
|
||||||
|
and_(
|
||||||
|
DomainListing.slug == slug,
|
||||||
|
DomainListing.status == ListingStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not listing:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
# Record view
|
||||||
|
view = ListingView(
|
||||||
|
listing_id=listing.id,
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
user_agent=request.headers.get("user-agent", "")[:500],
|
||||||
|
referrer=request.headers.get("referer", "")[:500],
|
||||||
|
user_id=current_user.id if current_user else None,
|
||||||
|
)
|
||||||
|
db.add(view)
|
||||||
|
|
||||||
|
# Increment view count
|
||||||
|
listing.view_count += 1
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ListingPublicResponse(
|
||||||
|
domain=listing.domain,
|
||||||
|
slug=listing.slug,
|
||||||
|
title=listing.title,
|
||||||
|
description=listing.description,
|
||||||
|
asking_price=listing.asking_price,
|
||||||
|
currency=listing.currency,
|
||||||
|
price_type=listing.price_type,
|
||||||
|
pounce_score=listing.pounce_score if listing.show_valuation else None,
|
||||||
|
estimated_value=listing.estimated_value if listing.show_valuation else None,
|
||||||
|
is_verified=listing.is_verified,
|
||||||
|
allow_offers=listing.allow_offers,
|
||||||
|
public_url=listing.public_url,
|
||||||
|
seller_verified=listing.is_verified,
|
||||||
|
seller_member_since=listing.user.created_at if listing.user else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{slug}/inquire")
|
||||||
|
async def submit_inquiry(
|
||||||
|
slug: str,
|
||||||
|
inquiry: InquiryCreate,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Submit an inquiry for a listing (public)."""
|
||||||
|
# Find listing
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing).where(
|
||||||
|
and_(
|
||||||
|
DomainListing.slug == slug,
|
||||||
|
DomainListing.status == ListingStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not listing:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
# Security: Check for phishing keywords
|
||||||
|
if not _check_content_safety(inquiry.message):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Message contains blocked content. Please revise."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rate limiting check (simple: max 3 inquiries per email per listing per day)
|
||||||
|
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
existing_count = await db.execute(
|
||||||
|
select(func.count(ListingInquiry.id)).where(
|
||||||
|
and_(
|
||||||
|
ListingInquiry.listing_id == listing.id,
|
||||||
|
ListingInquiry.email == inquiry.email.lower(),
|
||||||
|
ListingInquiry.created_at >= today_start,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing_count.scalar() >= 3:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many inquiries. Please try again tomorrow."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create inquiry
|
||||||
|
new_inquiry = ListingInquiry(
|
||||||
|
listing_id=listing.id,
|
||||||
|
name=inquiry.name,
|
||||||
|
email=inquiry.email.lower(),
|
||||||
|
phone=inquiry.phone,
|
||||||
|
company=inquiry.company,
|
||||||
|
message=inquiry.message,
|
||||||
|
offer_amount=inquiry.offer_amount,
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
user_agent=request.headers.get("user-agent", "")[:500],
|
||||||
|
)
|
||||||
|
db.add(new_inquiry)
|
||||||
|
|
||||||
|
# Increment inquiry count
|
||||||
|
listing.inquiry_count += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# TODO: Send email notification to seller
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Your inquiry has been sent to the seller.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Authenticated Endpoints ==============
|
||||||
|
|
||||||
|
@router.post("", response_model=ListingResponse)
|
||||||
|
async def create_listing(
|
||||||
|
data: ListingCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new domain listing."""
|
||||||
|
# Check if domain is already listed
|
||||||
|
existing = await db.execute(
|
||||||
|
select(DomainListing).where(DomainListing.domain == data.domain.lower())
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="This domain is already listed")
|
||||||
|
|
||||||
|
# Check user's listing limit based on subscription
|
||||||
|
user_listings = await db.execute(
|
||||||
|
select(func.count(DomainListing.id)).where(
|
||||||
|
DomainListing.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing_count = user_listings.scalar() or 0
|
||||||
|
|
||||||
|
# Listing limits by tier
|
||||||
|
tier = current_user.subscription.tier if current_user.subscription else "scout"
|
||||||
|
limits = {"scout": 2, "trader": 10, "tycoon": 50}
|
||||||
|
max_listings = limits.get(tier, 2)
|
||||||
|
|
||||||
|
if listing_count >= max_listings:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Listing limit reached ({max_listings}). Upgrade your plan for more."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate slug
|
||||||
|
slug = _generate_slug(data.domain)
|
||||||
|
|
||||||
|
# Check slug uniqueness
|
||||||
|
slug_check = await db.execute(
|
||||||
|
select(DomainListing).where(DomainListing.slug == slug)
|
||||||
|
)
|
||||||
|
if slug_check.scalar_one_or_none():
|
||||||
|
slug = f"{slug}-{secrets.token_hex(4)}"
|
||||||
|
|
||||||
|
# Get valuation
|
||||||
|
try:
|
||||||
|
valuation = await valuation_service.estimate_value(data.domain, db, save_result=False)
|
||||||
|
pounce_score = min(100, int(valuation.get("score", 50)))
|
||||||
|
estimated_value = valuation.get("estimated_value", 0)
|
||||||
|
except Exception:
|
||||||
|
pounce_score = 50
|
||||||
|
estimated_value = None
|
||||||
|
|
||||||
|
# Create listing
|
||||||
|
listing = DomainListing(
|
||||||
|
user_id=current_user.id,
|
||||||
|
domain=data.domain.lower(),
|
||||||
|
slug=slug,
|
||||||
|
title=data.title,
|
||||||
|
description=data.description,
|
||||||
|
asking_price=data.asking_price,
|
||||||
|
min_offer=data.min_offer,
|
||||||
|
currency=data.currency.upper(),
|
||||||
|
price_type=data.price_type,
|
||||||
|
show_valuation=data.show_valuation,
|
||||||
|
allow_offers=data.allow_offers,
|
||||||
|
pounce_score=pounce_score,
|
||||||
|
estimated_value=estimated_value,
|
||||||
|
verification_code=_generate_verification_code(),
|
||||||
|
status=ListingStatus.DRAFT.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(listing)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(listing)
|
||||||
|
|
||||||
|
return ListingResponse(
|
||||||
|
id=listing.id,
|
||||||
|
domain=listing.domain,
|
||||||
|
slug=listing.slug,
|
||||||
|
title=listing.title,
|
||||||
|
description=listing.description,
|
||||||
|
asking_price=listing.asking_price,
|
||||||
|
min_offer=listing.min_offer,
|
||||||
|
currency=listing.currency,
|
||||||
|
price_type=listing.price_type,
|
||||||
|
pounce_score=listing.pounce_score,
|
||||||
|
estimated_value=listing.estimated_value,
|
||||||
|
verification_status=listing.verification_status,
|
||||||
|
is_verified=listing.is_verified,
|
||||||
|
status=listing.status,
|
||||||
|
show_valuation=listing.show_valuation,
|
||||||
|
allow_offers=listing.allow_offers,
|
||||||
|
view_count=listing.view_count,
|
||||||
|
inquiry_count=listing.inquiry_count,
|
||||||
|
public_url=listing.public_url,
|
||||||
|
created_at=listing.created_at,
|
||||||
|
published_at=listing.published_at,
|
||||||
|
seller_verified=current_user.is_verified,
|
||||||
|
seller_member_since=current_user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my", response_model=List[ListingResponse])
|
||||||
|
async def get_my_listings(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get current user's listings."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing)
|
||||||
|
.where(DomainListing.user_id == current_user.id)
|
||||||
|
.order_by(DomainListing.created_at.desc())
|
||||||
|
)
|
||||||
|
listings = list(result.scalars().all())
|
||||||
|
|
||||||
|
return [
|
||||||
|
ListingResponse(
|
||||||
|
id=listing.id,
|
||||||
|
domain=listing.domain,
|
||||||
|
slug=listing.slug,
|
||||||
|
title=listing.title,
|
||||||
|
description=listing.description,
|
||||||
|
asking_price=listing.asking_price,
|
||||||
|
min_offer=listing.min_offer,
|
||||||
|
currency=listing.currency,
|
||||||
|
price_type=listing.price_type,
|
||||||
|
pounce_score=listing.pounce_score,
|
||||||
|
estimated_value=listing.estimated_value,
|
||||||
|
verification_status=listing.verification_status,
|
||||||
|
is_verified=listing.is_verified,
|
||||||
|
status=listing.status,
|
||||||
|
show_valuation=listing.show_valuation,
|
||||||
|
allow_offers=listing.allow_offers,
|
||||||
|
view_count=listing.view_count,
|
||||||
|
inquiry_count=listing.inquiry_count,
|
||||||
|
public_url=listing.public_url,
|
||||||
|
created_at=listing.created_at,
|
||||||
|
published_at=listing.published_at,
|
||||||
|
seller_verified=current_user.is_verified,
|
||||||
|
seller_member_since=current_user.created_at,
|
||||||
|
)
|
||||||
|
for listing in listings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}/inquiries", response_model=List[InquiryResponse])
|
||||||
|
async def get_listing_inquiries(
|
||||||
|
id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get inquiries for a listing."""
|
||||||
|
# Verify ownership
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing).where(
|
||||||
|
and_(
|
||||||
|
DomainListing.id == id,
|
||||||
|
DomainListing.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not listing:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
inquiries_result = await db.execute(
|
||||||
|
select(ListingInquiry)
|
||||||
|
.where(ListingInquiry.listing_id == id)
|
||||||
|
.order_by(ListingInquiry.created_at.desc())
|
||||||
|
)
|
||||||
|
inquiries = list(inquiries_result.scalars().all())
|
||||||
|
|
||||||
|
return [
|
||||||
|
InquiryResponse(
|
||||||
|
id=inq.id,
|
||||||
|
name=inq.name,
|
||||||
|
email=inq.email,
|
||||||
|
phone=inq.phone,
|
||||||
|
company=inq.company,
|
||||||
|
message=inq.message,
|
||||||
|
offer_amount=inq.offer_amount,
|
||||||
|
status=inq.status,
|
||||||
|
created_at=inq.created_at,
|
||||||
|
read_at=inq.read_at,
|
||||||
|
)
|
||||||
|
for inq in inquiries
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{id}", response_model=ListingResponse)
|
||||||
|
async def update_listing(
|
||||||
|
id: int,
|
||||||
|
data: ListingUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a listing."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing).where(
|
||||||
|
and_(
|
||||||
|
DomainListing.id == id,
|
||||||
|
DomainListing.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not listing:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if data.title is not None:
|
||||||
|
listing.title = data.title
|
||||||
|
if data.description is not None:
|
||||||
|
listing.description = data.description
|
||||||
|
if data.asking_price is not None:
|
||||||
|
listing.asking_price = data.asking_price
|
||||||
|
if data.min_offer is not None:
|
||||||
|
listing.min_offer = data.min_offer
|
||||||
|
if data.price_type is not None:
|
||||||
|
listing.price_type = data.price_type
|
||||||
|
if data.show_valuation is not None:
|
||||||
|
listing.show_valuation = data.show_valuation
|
||||||
|
if data.allow_offers is not None:
|
||||||
|
listing.allow_offers = data.allow_offers
|
||||||
|
|
||||||
|
# Status change
|
||||||
|
if data.status is not None:
|
||||||
|
if data.status == "active" and listing.status == "draft":
|
||||||
|
# Publish listing
|
||||||
|
if not listing.is_verified:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot publish without DNS verification"
|
||||||
|
)
|
||||||
|
listing.status = ListingStatus.ACTIVE.value
|
||||||
|
listing.published_at = datetime.utcnow()
|
||||||
|
elif data.status in ["draft", "sold", "expired"]:
|
||||||
|
listing.status = data.status
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(listing)
|
||||||
|
|
||||||
|
return ListingResponse(
|
||||||
|
id=listing.id,
|
||||||
|
domain=listing.domain,
|
||||||
|
slug=listing.slug,
|
||||||
|
title=listing.title,
|
||||||
|
description=listing.description,
|
||||||
|
asking_price=listing.asking_price,
|
||||||
|
min_offer=listing.min_offer,
|
||||||
|
currency=listing.currency,
|
||||||
|
price_type=listing.price_type,
|
||||||
|
pounce_score=listing.pounce_score,
|
||||||
|
estimated_value=listing.estimated_value,
|
||||||
|
verification_status=listing.verification_status,
|
||||||
|
is_verified=listing.is_verified,
|
||||||
|
status=listing.status,
|
||||||
|
show_valuation=listing.show_valuation,
|
||||||
|
allow_offers=listing.allow_offers,
|
||||||
|
view_count=listing.view_count,
|
||||||
|
inquiry_count=listing.inquiry_count,
|
||||||
|
public_url=listing.public_url,
|
||||||
|
created_at=listing.created_at,
|
||||||
|
published_at=listing.published_at,
|
||||||
|
seller_verified=current_user.is_verified,
|
||||||
|
seller_member_since=current_user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{id}")
|
||||||
|
async def delete_listing(
|
||||||
|
id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a listing."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing).where(
|
||||||
|
and_(
|
||||||
|
DomainListing.id == id,
|
||||||
|
DomainListing.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not listing:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
await db.delete(listing)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Listing deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{id}/verify-dns", response_model=VerificationResponse)
|
||||||
|
async def start_dns_verification(
|
||||||
|
id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Start DNS verification for a listing."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing).where(
|
||||||
|
and_(
|
||||||
|
DomainListing.id == id,
|
||||||
|
DomainListing.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not listing:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
# Generate new code if needed
|
||||||
|
if not listing.verification_code:
|
||||||
|
listing.verification_code = _generate_verification_code()
|
||||||
|
|
||||||
|
listing.verification_status = VerificationStatus.PENDING.value
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Extract domain root for DNS
|
||||||
|
domain_parts = listing.domain.split('.')
|
||||||
|
if len(domain_parts) > 2:
|
||||||
|
dns_name = f"_pounce.{'.'.join(domain_parts[-2:])}"
|
||||||
|
else:
|
||||||
|
dns_name = f"_pounce.{listing.domain}"
|
||||||
|
|
||||||
|
return VerificationResponse(
|
||||||
|
verification_code=listing.verification_code,
|
||||||
|
dns_record_type="TXT",
|
||||||
|
dns_record_name=dns_name,
|
||||||
|
dns_record_value=listing.verification_code,
|
||||||
|
instructions=f"""
|
||||||
|
To verify ownership of {listing.domain}:
|
||||||
|
|
||||||
|
1. Go to your domain registrar's DNS settings
|
||||||
|
2. Add a new TXT record:
|
||||||
|
- Name/Host: _pounce (or _pounce.{listing.domain})
|
||||||
|
- Value: {listing.verification_code}
|
||||||
|
- TTL: 300 (or lowest available)
|
||||||
|
3. Wait 1-5 minutes for DNS propagation
|
||||||
|
4. Click "Check Verification" to complete
|
||||||
|
|
||||||
|
This proves you control the domain's DNS, confirming ownership.
|
||||||
|
""".strip(),
|
||||||
|
status=listing.verification_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}/verify-dns/check")
|
||||||
|
async def check_dns_verification(
|
||||||
|
id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Check DNS verification status."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(DomainListing).where(
|
||||||
|
and_(
|
||||||
|
DomainListing.id == id,
|
||||||
|
DomainListing.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
listing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not listing:
|
||||||
|
raise HTTPException(status_code=404, detail="Listing not found")
|
||||||
|
|
||||||
|
if not listing.verification_code:
|
||||||
|
raise HTTPException(status_code=400, detail="Start verification first")
|
||||||
|
|
||||||
|
# Check DNS TXT record
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
try:
|
||||||
|
domain_parts = listing.domain.split('.')
|
||||||
|
if len(domain_parts) > 2:
|
||||||
|
dns_name = f"_pounce.{'.'.join(domain_parts[-2:])}"
|
||||||
|
else:
|
||||||
|
dns_name = f"_pounce.{listing.domain}"
|
||||||
|
|
||||||
|
answers = dns.resolver.resolve(dns_name, 'TXT')
|
||||||
|
|
||||||
|
for rdata in answers:
|
||||||
|
txt_value = str(rdata).strip('"')
|
||||||
|
if txt_value == listing.verification_code:
|
||||||
|
# Verified!
|
||||||
|
listing.verification_status = VerificationStatus.VERIFIED.value
|
||||||
|
listing.verified_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"verified": True,
|
||||||
|
"status": "verified",
|
||||||
|
"message": "DNS verification successful! You can now publish your listing.",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Code not found
|
||||||
|
return {
|
||||||
|
"verified": False,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "TXT record found but value doesn't match. Please check the value.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except dns.resolver.NXDOMAIN:
|
||||||
|
return {
|
||||||
|
"verified": False,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "DNS record not found. Please add the TXT record and wait for propagation.",
|
||||||
|
}
|
||||||
|
except dns.resolver.NoAnswer:
|
||||||
|
return {
|
||||||
|
"verified": False,
|
||||||
|
"status": "pending",
|
||||||
|
"message": "No TXT record found. Please add the record.",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DNS check failed for {listing.domain}: {e}")
|
||||||
|
return {
|
||||||
|
"verified": False,
|
||||||
|
"status": "error",
|
||||||
|
"message": "DNS check failed. Please try again in a few minutes.",
|
||||||
|
}
|
||||||
|
|
||||||
457
backend/app/api/sniper_alerts.py
Normal file
457
backend/app/api/sniper_alerts.py
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
"""
|
||||||
|
Sniper Alerts API - Hyper-personalized auction notifications
|
||||||
|
|
||||||
|
This implements "Strategie 4: Alerts nach Maß" from analysis_3.md:
|
||||||
|
"Der User kann extrem spezifische Filter speichern:
|
||||||
|
- Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält."
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /sniper-alerts - Get user's alerts
|
||||||
|
- POST /sniper-alerts - Create new alert
|
||||||
|
- PUT /sniper-alerts/{id} - Update alert
|
||||||
|
- DELETE /sniper-alerts/{id} - Delete alert
|
||||||
|
- GET /sniper-alerts/{id}/matches - Get matched auctions
|
||||||
|
- POST /sniper-alerts/{id}/test - Test alert against current auctions
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, func, and_
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||||
|
from app.models.auction import DomainAuction
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Schemas ==============
|
||||||
|
|
||||||
|
class SniperAlertCreate(BaseModel):
|
||||||
|
"""Create a new sniper alert."""
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
|
||||||
|
# Filter criteria
|
||||||
|
tlds: Optional[str] = Field(None, description="Comma-separated TLDs: com,io,ai")
|
||||||
|
keywords: Optional[str] = Field(None, description="Must contain (comma-separated)")
|
||||||
|
exclude_keywords: Optional[str] = Field(None, description="Must not contain")
|
||||||
|
max_length: Optional[int] = Field(None, ge=1, le=63)
|
||||||
|
min_length: Optional[int] = Field(None, ge=1, le=63)
|
||||||
|
max_price: Optional[float] = Field(None, ge=0)
|
||||||
|
min_price: Optional[float] = Field(None, ge=0)
|
||||||
|
max_bids: Optional[int] = Field(None, ge=0, description="Max bids (low competition)")
|
||||||
|
ending_within_hours: Optional[int] = Field(None, ge=1, le=168)
|
||||||
|
platforms: Optional[str] = Field(None, description="Comma-separated platforms")
|
||||||
|
|
||||||
|
# Advanced
|
||||||
|
no_numbers: bool = False
|
||||||
|
no_hyphens: bool = False
|
||||||
|
exclude_chars: Optional[str] = Field(None, description="Chars to exclude: q,x,z")
|
||||||
|
|
||||||
|
# Notifications
|
||||||
|
notify_email: bool = True
|
||||||
|
notify_sms: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SniperAlertUpdate(BaseModel):
|
||||||
|
"""Update a sniper alert."""
|
||||||
|
name: Optional[str] = Field(None, max_length=100)
|
||||||
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
|
tlds: Optional[str] = None
|
||||||
|
keywords: Optional[str] = None
|
||||||
|
exclude_keywords: Optional[str] = None
|
||||||
|
max_length: Optional[int] = Field(None, ge=1, le=63)
|
||||||
|
min_length: Optional[int] = Field(None, ge=1, le=63)
|
||||||
|
max_price: Optional[float] = Field(None, ge=0)
|
||||||
|
min_price: Optional[float] = Field(None, ge=0)
|
||||||
|
max_bids: Optional[int] = Field(None, ge=0)
|
||||||
|
ending_within_hours: Optional[int] = Field(None, ge=1, le=168)
|
||||||
|
platforms: Optional[str] = None
|
||||||
|
no_numbers: Optional[bool] = None
|
||||||
|
no_hyphens: Optional[bool] = None
|
||||||
|
exclude_chars: Optional[str] = None
|
||||||
|
notify_email: Optional[bool] = None
|
||||||
|
notify_sms: Optional[bool] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SniperAlertResponse(BaseModel):
|
||||||
|
"""Sniper alert response."""
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: Optional[str]
|
||||||
|
tlds: Optional[str]
|
||||||
|
keywords: Optional[str]
|
||||||
|
exclude_keywords: Optional[str]
|
||||||
|
max_length: Optional[int]
|
||||||
|
min_length: Optional[int]
|
||||||
|
max_price: Optional[float]
|
||||||
|
min_price: Optional[float]
|
||||||
|
max_bids: Optional[int]
|
||||||
|
ending_within_hours: Optional[int]
|
||||||
|
platforms: Optional[str]
|
||||||
|
no_numbers: bool
|
||||||
|
no_hyphens: bool
|
||||||
|
exclude_chars: Optional[str]
|
||||||
|
notify_email: bool
|
||||||
|
notify_sms: bool
|
||||||
|
is_active: bool
|
||||||
|
matches_count: int
|
||||||
|
notifications_sent: int
|
||||||
|
last_matched_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MatchResponse(BaseModel):
|
||||||
|
"""Alert match response."""
|
||||||
|
id: int
|
||||||
|
domain: str
|
||||||
|
platform: str
|
||||||
|
current_bid: float
|
||||||
|
end_time: datetime
|
||||||
|
auction_url: Optional[str]
|
||||||
|
matched_at: datetime
|
||||||
|
notified: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Endpoints ==============
|
||||||
|
|
||||||
|
@router.get("", response_model=List[SniperAlertResponse])
|
||||||
|
async def get_sniper_alerts(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get user's sniper alerts."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SniperAlert)
|
||||||
|
.where(SniperAlert.user_id == current_user.id)
|
||||||
|
.order_by(SniperAlert.created_at.desc())
|
||||||
|
)
|
||||||
|
alerts = list(result.scalars().all())
|
||||||
|
|
||||||
|
return [
|
||||||
|
SniperAlertResponse(
|
||||||
|
id=alert.id,
|
||||||
|
name=alert.name,
|
||||||
|
description=alert.description,
|
||||||
|
tlds=alert.tlds,
|
||||||
|
keywords=alert.keywords,
|
||||||
|
exclude_keywords=alert.exclude_keywords,
|
||||||
|
max_length=alert.max_length,
|
||||||
|
min_length=alert.min_length,
|
||||||
|
max_price=alert.max_price,
|
||||||
|
min_price=alert.min_price,
|
||||||
|
max_bids=alert.max_bids,
|
||||||
|
ending_within_hours=alert.ending_within_hours,
|
||||||
|
platforms=alert.platforms,
|
||||||
|
no_numbers=alert.no_numbers,
|
||||||
|
no_hyphens=alert.no_hyphens,
|
||||||
|
exclude_chars=alert.exclude_chars,
|
||||||
|
notify_email=alert.notify_email,
|
||||||
|
notify_sms=alert.notify_sms,
|
||||||
|
is_active=alert.is_active,
|
||||||
|
matches_count=alert.matches_count,
|
||||||
|
notifications_sent=alert.notifications_sent,
|
||||||
|
last_matched_at=alert.last_matched_at,
|
||||||
|
created_at=alert.created_at,
|
||||||
|
)
|
||||||
|
for alert in alerts
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SniperAlertResponse)
|
||||||
|
async def create_sniper_alert(
|
||||||
|
data: SniperAlertCreate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new sniper alert."""
|
||||||
|
# Check alert limit based on subscription
|
||||||
|
user_alerts = await db.execute(
|
||||||
|
select(func.count(SniperAlert.id)).where(
|
||||||
|
SniperAlert.user_id == current_user.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
alert_count = user_alerts.scalar() or 0
|
||||||
|
|
||||||
|
tier = current_user.subscription.tier if current_user.subscription else "scout"
|
||||||
|
limits = {"scout": 2, "trader": 10, "tycoon": 50}
|
||||||
|
max_alerts = limits.get(tier, 2)
|
||||||
|
|
||||||
|
if alert_count >= max_alerts:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"Alert limit reached ({max_alerts}). Upgrade for more."
|
||||||
|
)
|
||||||
|
|
||||||
|
# SMS notifications are Tycoon only
|
||||||
|
if data.notify_sms and tier != "tycoon":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="SMS notifications are a Tycoon feature"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build filter criteria JSON
|
||||||
|
filter_criteria = {
|
||||||
|
"tlds": data.tlds.split(',') if data.tlds else None,
|
||||||
|
"keywords": data.keywords.split(',') if data.keywords else None,
|
||||||
|
"exclude_keywords": data.exclude_keywords.split(',') if data.exclude_keywords else None,
|
||||||
|
"max_length": data.max_length,
|
||||||
|
"min_length": data.min_length,
|
||||||
|
"max_price": data.max_price,
|
||||||
|
"min_price": data.min_price,
|
||||||
|
"max_bids": data.max_bids,
|
||||||
|
"ending_within_hours": data.ending_within_hours,
|
||||||
|
"platforms": data.platforms.split(',') if data.platforms else None,
|
||||||
|
"no_numbers": data.no_numbers,
|
||||||
|
"no_hyphens": data.no_hyphens,
|
||||||
|
"exclude_chars": data.exclude_chars.split(',') if data.exclude_chars else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
alert = SniperAlert(
|
||||||
|
user_id=current_user.id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
filter_criteria=filter_criteria,
|
||||||
|
tlds=data.tlds,
|
||||||
|
keywords=data.keywords,
|
||||||
|
exclude_keywords=data.exclude_keywords,
|
||||||
|
max_length=data.max_length,
|
||||||
|
min_length=data.min_length,
|
||||||
|
max_price=data.max_price,
|
||||||
|
min_price=data.min_price,
|
||||||
|
max_bids=data.max_bids,
|
||||||
|
ending_within_hours=data.ending_within_hours,
|
||||||
|
platforms=data.platforms,
|
||||||
|
no_numbers=data.no_numbers,
|
||||||
|
no_hyphens=data.no_hyphens,
|
||||||
|
exclude_chars=data.exclude_chars,
|
||||||
|
notify_email=data.notify_email,
|
||||||
|
notify_sms=data.notify_sms,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(alert)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(alert)
|
||||||
|
|
||||||
|
return SniperAlertResponse(
|
||||||
|
id=alert.id,
|
||||||
|
name=alert.name,
|
||||||
|
description=alert.description,
|
||||||
|
tlds=alert.tlds,
|
||||||
|
keywords=alert.keywords,
|
||||||
|
exclude_keywords=alert.exclude_keywords,
|
||||||
|
max_length=alert.max_length,
|
||||||
|
min_length=alert.min_length,
|
||||||
|
max_price=alert.max_price,
|
||||||
|
min_price=alert.min_price,
|
||||||
|
max_bids=alert.max_bids,
|
||||||
|
ending_within_hours=alert.ending_within_hours,
|
||||||
|
platforms=alert.platforms,
|
||||||
|
no_numbers=alert.no_numbers,
|
||||||
|
no_hyphens=alert.no_hyphens,
|
||||||
|
exclude_chars=alert.exclude_chars,
|
||||||
|
notify_email=alert.notify_email,
|
||||||
|
notify_sms=alert.notify_sms,
|
||||||
|
is_active=alert.is_active,
|
||||||
|
matches_count=alert.matches_count,
|
||||||
|
notifications_sent=alert.notifications_sent,
|
||||||
|
last_matched_at=alert.last_matched_at,
|
||||||
|
created_at=alert.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{id}", response_model=SniperAlertResponse)
|
||||||
|
async def update_sniper_alert(
|
||||||
|
id: int,
|
||||||
|
data: SniperAlertUpdate,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update a sniper alert."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SniperAlert).where(
|
||||||
|
and_(
|
||||||
|
SniperAlert.id == id,
|
||||||
|
SniperAlert.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
alert = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
update_fields = data.model_dump(exclude_unset=True)
|
||||||
|
for field, value in update_fields.items():
|
||||||
|
if hasattr(alert, field):
|
||||||
|
setattr(alert, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(alert)
|
||||||
|
|
||||||
|
return SniperAlertResponse(
|
||||||
|
id=alert.id,
|
||||||
|
name=alert.name,
|
||||||
|
description=alert.description,
|
||||||
|
tlds=alert.tlds,
|
||||||
|
keywords=alert.keywords,
|
||||||
|
exclude_keywords=alert.exclude_keywords,
|
||||||
|
max_length=alert.max_length,
|
||||||
|
min_length=alert.min_length,
|
||||||
|
max_price=alert.max_price,
|
||||||
|
min_price=alert.min_price,
|
||||||
|
max_bids=alert.max_bids,
|
||||||
|
ending_within_hours=alert.ending_within_hours,
|
||||||
|
platforms=alert.platforms,
|
||||||
|
no_numbers=alert.no_numbers,
|
||||||
|
no_hyphens=alert.no_hyphens,
|
||||||
|
exclude_chars=alert.exclude_chars,
|
||||||
|
notify_email=alert.notify_email,
|
||||||
|
notify_sms=alert.notify_sms,
|
||||||
|
is_active=alert.is_active,
|
||||||
|
matches_count=alert.matches_count,
|
||||||
|
notifications_sent=alert.notifications_sent,
|
||||||
|
last_matched_at=alert.last_matched_at,
|
||||||
|
created_at=alert.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{id}")
|
||||||
|
async def delete_sniper_alert(
|
||||||
|
id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a sniper alert."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(SniperAlert).where(
|
||||||
|
and_(
|
||||||
|
SniperAlert.id == id,
|
||||||
|
SniperAlert.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
alert = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
await db.delete(alert)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Alert deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{id}/matches", response_model=List[MatchResponse])
|
||||||
|
async def get_alert_matches(
|
||||||
|
id: int,
|
||||||
|
limit: int = 50,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get matched auctions for an alert."""
|
||||||
|
# Verify ownership
|
||||||
|
result = await db.execute(
|
||||||
|
select(SniperAlert).where(
|
||||||
|
and_(
|
||||||
|
SniperAlert.id == id,
|
||||||
|
SniperAlert.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
alert = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
matches_result = await db.execute(
|
||||||
|
select(SniperAlertMatch)
|
||||||
|
.where(SniperAlertMatch.alert_id == id)
|
||||||
|
.order_by(SniperAlertMatch.matched_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
matches = list(matches_result.scalars().all())
|
||||||
|
|
||||||
|
return [
|
||||||
|
MatchResponse(
|
||||||
|
id=m.id,
|
||||||
|
domain=m.domain,
|
||||||
|
platform=m.platform,
|
||||||
|
current_bid=m.current_bid,
|
||||||
|
end_time=m.end_time,
|
||||||
|
auction_url=m.auction_url,
|
||||||
|
matched_at=m.matched_at,
|
||||||
|
notified=m.notified,
|
||||||
|
)
|
||||||
|
for m in matches
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{id}/test")
|
||||||
|
async def test_sniper_alert(
|
||||||
|
id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Test alert against current auctions."""
|
||||||
|
# Verify ownership
|
||||||
|
result = await db.execute(
|
||||||
|
select(SniperAlert).where(
|
||||||
|
and_(
|
||||||
|
SniperAlert.id == id,
|
||||||
|
SniperAlert.user_id == current_user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
alert = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
# Get active auctions
|
||||||
|
auctions_result = await db.execute(
|
||||||
|
select(DomainAuction)
|
||||||
|
.where(DomainAuction.is_active == True)
|
||||||
|
.limit(500)
|
||||||
|
)
|
||||||
|
auctions = list(auctions_result.scalars().all())
|
||||||
|
|
||||||
|
matches = []
|
||||||
|
for auction in auctions:
|
||||||
|
if alert.matches_domain(
|
||||||
|
auction.domain,
|
||||||
|
auction.tld,
|
||||||
|
auction.current_bid,
|
||||||
|
auction.num_bids
|
||||||
|
):
|
||||||
|
matches.append({
|
||||||
|
"domain": auction.domain,
|
||||||
|
"platform": auction.platform,
|
||||||
|
"current_bid": auction.current_bid,
|
||||||
|
"num_bids": auction.num_bids,
|
||||||
|
"end_time": auction.end_time.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alert_name": alert.name,
|
||||||
|
"auctions_checked": len(auctions),
|
||||||
|
"matches_found": len(matches),
|
||||||
|
"matches": matches[:20], # Limit to 20 for preview
|
||||||
|
"message": f"Found {len(matches)} matching auctions" if matches else "No matches found. Try adjusting your criteria.",
|
||||||
|
}
|
||||||
|
|
||||||
@ -9,6 +9,8 @@ from app.models.newsletter import NewsletterSubscriber
|
|||||||
from app.models.price_alert import PriceAlert
|
from app.models.price_alert import PriceAlert
|
||||||
from app.models.admin_log import AdminActivityLog
|
from app.models.admin_log import AdminActivityLog
|
||||||
from app.models.blog import BlogPost
|
from app.models.blog import BlogPost
|
||||||
|
from app.models.listing import DomainListing, ListingInquiry, ListingView
|
||||||
|
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@ -25,4 +27,11 @@ __all__ = [
|
|||||||
"PriceAlert",
|
"PriceAlert",
|
||||||
"AdminActivityLog",
|
"AdminActivityLog",
|
||||||
"BlogPost",
|
"BlogPost",
|
||||||
|
# New: For Sale / Marketplace
|
||||||
|
"DomainListing",
|
||||||
|
"ListingInquiry",
|
||||||
|
"ListingView",
|
||||||
|
# New: Sniper Alerts
|
||||||
|
"SniperAlert",
|
||||||
|
"SniperAlertMatch",
|
||||||
]
|
]
|
||||||
|
|||||||
203
backend/app/models/listing.py
Normal file
203
backend/app/models/listing.py
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
"""
|
||||||
|
Domain Listing models for "Pounce For Sale" feature.
|
||||||
|
|
||||||
|
This implements the "Micro-Marktplatz" strategy from analysis_3.md:
|
||||||
|
- Users can create professional landing pages for domains they want to sell
|
||||||
|
- Buyers can contact sellers through Pounce
|
||||||
|
- DNS verification ensures only real owners can list domains
|
||||||
|
|
||||||
|
DATABASE TABLES TO CREATE:
|
||||||
|
1. domain_listings - Main listing table
|
||||||
|
2. listing_inquiries - Contact requests from potential buyers
|
||||||
|
3. listing_views - Track views for analytics
|
||||||
|
|
||||||
|
Run migrations: alembic upgrade head
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, Enum as SQLEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
import enum
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class ListingStatus(str, enum.Enum):
|
||||||
|
"""Status of a domain listing."""
|
||||||
|
DRAFT = "draft" # Not yet published
|
||||||
|
PENDING_VERIFICATION = "pending_verification" # Awaiting DNS verification
|
||||||
|
ACTIVE = "active" # Live and visible
|
||||||
|
SOLD = "sold" # Marked as sold
|
||||||
|
EXPIRED = "expired" # Listing expired
|
||||||
|
SUSPENDED = "suspended" # Suspended by admin
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationStatus(str, enum.Enum):
|
||||||
|
"""DNS verification status."""
|
||||||
|
NOT_STARTED = "not_started"
|
||||||
|
PENDING = "pending"
|
||||||
|
VERIFIED = "verified"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class DomainListing(Base):
|
||||||
|
"""
|
||||||
|
Domain listing for the Pounce marketplace.
|
||||||
|
|
||||||
|
Users can list their domains for sale with a professional landing page.
|
||||||
|
URL: pounce.ch/buy/{slug}
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- DNS verification for ownership proof
|
||||||
|
- Professional landing page with valuation
|
||||||
|
- Contact form for buyers
|
||||||
|
- Analytics (views, inquiries)
|
||||||
|
|
||||||
|
From analysis_3.md:
|
||||||
|
"Ein User (Trader/Tycoon) kann für seine Domains mit einem Klick
|
||||||
|
eine schicke Verkaufsseite erstellen."
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "domain_listings"
|
||||||
|
|
||||||
|
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)
|
||||||
|
slug: Mapped[str] = mapped_column(String(300), unique=True, nullable=False, index=True)
|
||||||
|
|
||||||
|
# Listing details
|
||||||
|
title: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Custom headline
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Pricing
|
||||||
|
asking_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
min_offer: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
currency: Mapped[str] = mapped_column(String(3), default="USD")
|
||||||
|
price_type: Mapped[str] = mapped_column(String(20), default="fixed") # fixed, negotiable, make_offer
|
||||||
|
|
||||||
|
# Pounce valuation (calculated)
|
||||||
|
pounce_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # 0-100
|
||||||
|
estimated_value: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
|
# Verification (from analysis_3.md - Säule 2: Asset Verification)
|
||||||
|
verification_status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
default=VerificationStatus.NOT_STARTED.value
|
||||||
|
)
|
||||||
|
verification_code: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||||
|
verified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: Mapped[str] = mapped_column(String(30), default=ListingStatus.DRAFT.value, index=True)
|
||||||
|
|
||||||
|
# Features
|
||||||
|
show_valuation: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
allow_offers: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
featured: Mapped[bool] = mapped_column(Boolean, default=False) # Premium placement
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
view_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
inquiry_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
||||||
|
# Expiry
|
||||||
|
expires_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)
|
||||||
|
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="listings")
|
||||||
|
inquiries: Mapped[List["ListingInquiry"]] = relationship(
|
||||||
|
"ListingInquiry", back_populates="listing", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<DomainListing {self.domain} ({self.status})>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_verified(self) -> bool:
|
||||||
|
return self.verification_status == VerificationStatus.VERIFIED.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self) -> bool:
|
||||||
|
return self.status == ListingStatus.ACTIVE.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_url(self) -> str:
|
||||||
|
return f"/buy/{self.slug}"
|
||||||
|
|
||||||
|
|
||||||
|
class ListingInquiry(Base):
|
||||||
|
"""
|
||||||
|
Contact request from a potential buyer.
|
||||||
|
|
||||||
|
From analysis_3.md:
|
||||||
|
"Ein einfaches Kontaktformular, das die Anfrage direkt an den User leitet."
|
||||||
|
|
||||||
|
Security (from analysis_3.md - Säule 3):
|
||||||
|
- Keyword blocking for phishing prevention
|
||||||
|
- Rate limiting per IP/user
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "listing_inquiries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
||||||
|
|
||||||
|
# Inquirer info
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
phone: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||||
|
company: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||||
|
|
||||||
|
# Message
|
||||||
|
message: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
offer_amount: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="new") # new, read, replied, spam
|
||||||
|
|
||||||
|
# Tracking
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
replied_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
listing: Mapped["DomainListing"] = relationship("DomainListing", back_populates="inquiries")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ListingInquiry from {self.email} for listing #{self.listing_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class ListingView(Base):
|
||||||
|
"""
|
||||||
|
Track listing page views for analytics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "listing_views"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
listing_id: Mapped[int] = mapped_column(ForeignKey("domain_listings.id"), index=True, nullable=False)
|
||||||
|
|
||||||
|
# Visitor info
|
||||||
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||||
|
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
referrer: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# User (if logged in)
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id"), nullable=True)
|
||||||
|
|
||||||
|
# Timestamp
|
||||||
|
viewed_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<ListingView #{self.listing_id} at {self.viewed_at}>"
|
||||||
|
|
||||||
183
backend/app/models/sniper_alert.py
Normal file
183
backend/app/models/sniper_alert.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
Sniper Alert models for hyper-personalized auction alerts.
|
||||||
|
|
||||||
|
This implements "Strategie 4: Alerts nach Maß" from analysis_3.md:
|
||||||
|
"Der User kann extrem spezifische Filter speichern:
|
||||||
|
- Informiere mich NUR, wenn eine 4-Letter .com Domain droppt, die kein 'q' oder 'x' enthält.
|
||||||
|
- Informiere mich, wenn eine .ch Domain droppt, die das Wort 'Immo' enthält."
|
||||||
|
|
||||||
|
DATABASE TABLES TO CREATE:
|
||||||
|
1. sniper_alerts - Saved filter configurations
|
||||||
|
2. sniper_alert_matches - Matched auctions for each alert
|
||||||
|
3. sniper_alert_notifications - Sent notifications
|
||||||
|
|
||||||
|
Run migrations: alembic upgrade head
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy import String, DateTime, Float, Integer, Text, ForeignKey, Boolean, JSON
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SniperAlert(Base):
|
||||||
|
"""
|
||||||
|
Saved filter for hyper-personalized auction alerts.
|
||||||
|
|
||||||
|
Users can define very specific criteria and get notified
|
||||||
|
when matching domains appear in auctions.
|
||||||
|
|
||||||
|
Example filters:
|
||||||
|
- "4-letter .com without q or x"
|
||||||
|
- ".ch domains containing 'immo'"
|
||||||
|
- "Auctions under $100 ending in 1 hour"
|
||||||
|
|
||||||
|
From analysis_3.md:
|
||||||
|
"Wenn die SMS/Mail kommt, weiß der User: Das ist relevant."
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "sniper_alerts"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
|
||||||
|
|
||||||
|
# Alert name
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Filter criteria (stored as JSON for flexibility)
|
||||||
|
# Example: {"tlds": ["com", "io"], "max_length": 4, "exclude_chars": ["q", "x"]}
|
||||||
|
filter_criteria: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
|
||||||
|
# Individual filter fields (for database queries)
|
||||||
|
tlds: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Comma-separated: "com,io,ai"
|
||||||
|
keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must contain
|
||||||
|
exclude_keywords: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) # Must not contain
|
||||||
|
max_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
min_length: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
|
max_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
min_price: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
|
||||||
|
max_bids: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Low competition
|
||||||
|
ending_within_hours: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Urgency
|
||||||
|
platforms: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) # Comma-separated
|
||||||
|
|
||||||
|
# Advanced filters
|
||||||
|
no_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
no_hyphens: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
exclude_chars: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # "q,x,z"
|
||||||
|
|
||||||
|
# Notification settings
|
||||||
|
notify_email: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
notify_sms: Mapped[bool] = mapped_column(Boolean, default=False) # Tycoon feature
|
||||||
|
notify_push: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Frequency limits
|
||||||
|
max_notifications_per_day: Mapped[int] = mapped_column(Integer, default=10)
|
||||||
|
cooldown_minutes: Mapped[int] = mapped_column(Integer, default=30) # Min time between alerts
|
||||||
|
|
||||||
|
# Status
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
matches_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
notifications_sent: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
last_matched_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
last_notified_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="sniper_alerts")
|
||||||
|
matches: Mapped[List["SniperAlertMatch"]] = relationship(
|
||||||
|
"SniperAlertMatch", back_populates="alert", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<SniperAlert '{self.name}' (user={self.user_id})>"
|
||||||
|
|
||||||
|
def matches_domain(self, domain: str, tld: str, price: float, num_bids: int) -> bool:
|
||||||
|
"""Check if a domain matches this alert's criteria."""
|
||||||
|
name = domain.split('.')[0] if '.' in domain else domain
|
||||||
|
|
||||||
|
# TLD filter
|
||||||
|
if self.tlds:
|
||||||
|
allowed_tlds = [t.strip().lower() for t in self.tlds.split(',')]
|
||||||
|
if tld.lower() not in allowed_tlds:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Length filters
|
||||||
|
if self.max_length and len(name) > self.max_length:
|
||||||
|
return False
|
||||||
|
if self.min_length and len(name) < self.min_length:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Price filters
|
||||||
|
if self.max_price and price > self.max_price:
|
||||||
|
return False
|
||||||
|
if self.min_price and price < self.min_price:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Competition filter
|
||||||
|
if self.max_bids and num_bids > self.max_bids:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Keyword filters
|
||||||
|
if self.keywords:
|
||||||
|
required = [k.strip().lower() for k in self.keywords.split(',')]
|
||||||
|
if not any(kw in name.lower() for kw in required):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.exclude_keywords:
|
||||||
|
excluded = [k.strip().lower() for k in self.exclude_keywords.split(',')]
|
||||||
|
if any(kw in name.lower() for kw in excluded):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Character filters
|
||||||
|
if self.no_numbers and any(c.isdigit() for c in name):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.no_hyphens and '-' in name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.exclude_chars:
|
||||||
|
excluded_chars = [c.strip().lower() for c in self.exclude_chars.split(',')]
|
||||||
|
if any(c in name.lower() for c in excluded_chars):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SniperAlertMatch(Base):
|
||||||
|
"""
|
||||||
|
Record of a domain that matched a sniper alert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "sniper_alert_matches"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
alert_id: Mapped[int] = mapped_column(ForeignKey("sniper_alerts.id"), index=True, nullable=False)
|
||||||
|
|
||||||
|
# Matched auction info
|
||||||
|
domain: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
platform: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
current_bid: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
end_time: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
auction_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
# Status
|
||||||
|
notified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
clicked: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Timestamps
|
||||||
|
matched_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
alert: Mapped["SniperAlert"] = relationship("SniperAlert", back_populates="matches")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<SniperAlertMatch {self.domain} for alert #{self.alert_id}>"
|
||||||
|
|
||||||
@ -60,6 +60,14 @@ class User(Base):
|
|||||||
price_alerts: Mapped[List["PriceAlert"]] = relationship(
|
price_alerts: Mapped[List["PriceAlert"]] = relationship(
|
||||||
"PriceAlert", cascade="all, delete-orphan", passive_deletes=True
|
"PriceAlert", cascade="all, delete-orphan", passive_deletes=True
|
||||||
)
|
)
|
||||||
|
# For Sale Marketplace
|
||||||
|
listings: Mapped[List["DomainListing"]] = relationship(
|
||||||
|
"DomainListing", back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
# Sniper Alerts
|
||||||
|
sniper_alerts: Mapped[List["SniperAlert"]] = relationship(
|
||||||
|
"SniperAlert", 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}>"
|
||||||
|
|||||||
460
frontend/src/app/buy/[slug]/page.tsx
Normal file
460
frontend/src/app/buy/[slug]/page.tsx
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { Header } from '@/components/Header'
|
||||||
|
import { Footer } from '@/components/Footer'
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
DollarSign,
|
||||||
|
Mail,
|
||||||
|
User,
|
||||||
|
Building,
|
||||||
|
Phone,
|
||||||
|
MessageSquare,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
Globe,
|
||||||
|
Calendar,
|
||||||
|
ExternalLink,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface Listing {
|
||||||
|
domain: string
|
||||||
|
slug: string
|
||||||
|
title: string | null
|
||||||
|
description: string | null
|
||||||
|
asking_price: number | null
|
||||||
|
currency: string
|
||||||
|
price_type: string
|
||||||
|
pounce_score: number | null
|
||||||
|
estimated_value: number | null
|
||||||
|
is_verified: boolean
|
||||||
|
allow_offers: boolean
|
||||||
|
public_url: string
|
||||||
|
seller_verified: boolean
|
||||||
|
seller_member_since: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuyDomainPage() {
|
||||||
|
const params = useParams()
|
||||||
|
const slug = params.slug as string
|
||||||
|
|
||||||
|
const [listing, setListing] = useState<Listing | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Inquiry form state
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
company: '',
|
||||||
|
message: '',
|
||||||
|
offer_amount: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadListing()
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const loadListing = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await api.request<Listing>(`/listings/${slug}`)
|
||||||
|
setListing(data)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Listing not found')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.request(`/listings/${slug}/inquire`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...formData,
|
||||||
|
offer_amount: formData.offer_amount ? parseFloat(formData.offer_amount) : null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
setSubmitted(true)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to submit inquiry')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'text-accent'
|
||||||
|
if (score >= 60) return 'text-amber-400'
|
||||||
|
return 'text-foreground-muted'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !listing) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<main className="pt-32 pb-20 px-4">
|
||||||
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
|
<AlertCircle className="w-16 h-16 text-foreground-muted mx-auto mb-6" />
|
||||||
|
<h1 className="text-2xl font-display text-foreground mb-4">Domain Not Available</h1>
|
||||||
|
<p className="text-foreground-muted mb-8">
|
||||||
|
This listing may have been sold, removed, or doesn't exist.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/buy"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
Browse Listings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
|
{/* Background Effects */}
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Domain Hero */}
|
||||||
|
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
||||||
|
{listing.is_verified && (
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-accent/10 text-accent text-sm font-medium rounded-full mb-6">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Verified Owner
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="font-display text-[2.5rem] sm:text-[4rem] md:text-[5rem] lg:text-[6rem] leading-[0.95] tracking-[-0.03em] text-foreground mb-6">
|
||||||
|
{listing.domain}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{listing.title && (
|
||||||
|
<p className="text-xl sm:text-2xl text-foreground-muted max-w-2xl mx-auto mb-8">
|
||||||
|
{listing.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price Badge */}
|
||||||
|
<div className="inline-flex items-center gap-4 px-6 py-4 bg-background-secondary/50 border border-border rounded-2xl">
|
||||||
|
{listing.asking_price ? (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-foreground-muted uppercase tracking-wider">
|
||||||
|
{listing.price_type === 'fixed' ? 'Price' : 'Asking'}
|
||||||
|
</span>
|
||||||
|
<span className="text-3xl sm:text-4xl font-display text-foreground">
|
||||||
|
{formatPrice(listing.asking_price, listing.currency)}
|
||||||
|
</span>
|
||||||
|
{listing.price_type === 'negotiable' && (
|
||||||
|
<span className="text-sm text-accent bg-accent/10 px-2 py-1 rounded">
|
||||||
|
Negotiable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DollarSign className="w-6 h-6 text-accent" />
|
||||||
|
<span className="text-2xl font-display text-foreground">Make an Offer</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-8">
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-2 space-y-8">
|
||||||
|
{/* Description */}
|
||||||
|
{listing.description && (
|
||||||
|
<div className="p-6 bg-background-secondary/30 border border-border rounded-2xl animate-slide-up">
|
||||||
|
<h2 className="text-lg font-medium text-foreground mb-4 flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5 text-accent" />
|
||||||
|
About This Domain
|
||||||
|
</h2>
|
||||||
|
<p className="text-foreground-muted whitespace-pre-line">
|
||||||
|
{listing.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pounce Valuation */}
|
||||||
|
{listing.pounce_score && listing.estimated_value && (
|
||||||
|
<div className="p-6 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl animate-slide-up">
|
||||||
|
<h2 className="text-lg font-medium text-foreground mb-4 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-accent" />
|
||||||
|
Pounce Valuation
|
||||||
|
</h2>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-foreground-muted mb-1">Domain Score</p>
|
||||||
|
<p className={clsx("text-4xl font-display", getScoreColor(listing.pounce_score))}>
|
||||||
|
{listing.pounce_score}
|
||||||
|
<span className="text-lg text-foreground-muted">/100</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-foreground-muted mb-1">Estimated Value</p>
|
||||||
|
<p className="text-4xl font-display text-foreground">
|
||||||
|
{formatPrice(listing.estimated_value, listing.currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-xs text-foreground-subtle">
|
||||||
|
Valuation based on domain length, TLD, keywords, and market data.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trust Indicators */}
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4 animate-slide-up">
|
||||||
|
<div className="p-4 bg-background-secondary/30 border border-border rounded-xl flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Shield className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{listing.is_verified ? 'Verified' : 'Pending'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground-muted">Ownership</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-background-secondary/30 border border-border rounded-xl flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-foreground/5 rounded-lg flex items-center justify-center">
|
||||||
|
<Globe className="w-5 h-5 text-foreground-muted" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
.{listing.domain.split('.').pop()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground-muted">Extension</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listing.seller_member_since && (
|
||||||
|
<div className="p-4 bg-background-secondary/30 border border-border rounded-xl flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-foreground/5 rounded-lg flex items-center justify-center">
|
||||||
|
<Calendar className="w-5 h-5 text-foreground-muted" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{new Date(listing.seller_member_since).getFullYear()}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-foreground-muted">Member Since</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar - Contact Form */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<div className="sticky top-32 p-6 bg-background-secondary/30 border border-border rounded-2xl animate-slide-up">
|
||||||
|
{submitted ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckCircle className="w-16 h-16 text-accent mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">Inquiry Sent!</h3>
|
||||||
|
<p className="text-sm text-foreground-muted">
|
||||||
|
The seller will respond to your message directly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : showForm ? (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">Contact Seller</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Name *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
placeholder="Your name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Email *</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Phone</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
placeholder="+1 (555) 000-0000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Company</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Building className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.company}
|
||||||
|
onChange={(e) => setFormData({ ...formData, company: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
placeholder="Your company"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listing.allow_offers && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Your Offer</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.offer_amount}
|
||||||
|
onChange={(e) => setFormData({ ...formData, offer_amount: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
placeholder="Amount in USD"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Message *</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
value={formData.message}
|
||||||
|
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent resize-none"
|
||||||
|
placeholder="I'm interested in acquiring this domain..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
Send Inquiry
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowForm(false)}
|
||||||
|
className="w-full text-sm text-foreground-muted hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-2">Interested?</h3>
|
||||||
|
<p className="text-sm text-foreground-muted mb-6">
|
||||||
|
Contact the seller directly through Pounce.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
<Mail className="w-5 h-5" />
|
||||||
|
Contact Seller
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{listing.allow_offers && listing.asking_price && (
|
||||||
|
<p className="mt-4 text-xs text-foreground-subtle">
|
||||||
|
Price is negotiable. Make an offer!
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Powered by Pounce */}
|
||||||
|
<div className="mt-16 text-center animate-fade-in">
|
||||||
|
<p className="text-sm text-foreground-subtle flex items-center justify-center gap-2">
|
||||||
|
<img src="/pounce_puma.png" alt="Pounce" className="w-5 h-5 opacity-50" />
|
||||||
|
Marketplace powered by Pounce
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
304
frontend/src/app/buy/page.tsx
Normal file
304
frontend/src/app/buy/page.tsx
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { Header } from '@/components/Header'
|
||||||
|
import { Footer } from '@/components/Footer'
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
DollarSign,
|
||||||
|
X,
|
||||||
|
Lock,
|
||||||
|
Sparkles,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
ExternalLink,
|
||||||
|
Eye,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface Listing {
|
||||||
|
domain: string
|
||||||
|
slug: string
|
||||||
|
title: string | null
|
||||||
|
description: string | null
|
||||||
|
asking_price: number | null
|
||||||
|
currency: string
|
||||||
|
price_type: string
|
||||||
|
pounce_score: number | null
|
||||||
|
estimated_value: number | null
|
||||||
|
is_verified: boolean
|
||||||
|
allow_offers: boolean
|
||||||
|
public_url: string
|
||||||
|
seller_verified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrowseListingsPage() {
|
||||||
|
const [listings, setListings] = useState<Listing[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [minPrice, setMinPrice] = useState('')
|
||||||
|
const [maxPrice, setMaxPrice] = useState('')
|
||||||
|
const [verifiedOnly, setVerifiedOnly] = useState(false)
|
||||||
|
const [sortBy, setSortBy] = useState<'newest' | 'price_asc' | 'price_desc' | 'popular'>('newest')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadListings()
|
||||||
|
}, [sortBy, verifiedOnly])
|
||||||
|
|
||||||
|
const loadListings = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.append('sort_by', sortBy)
|
||||||
|
if (verifiedOnly) params.append('verified_only', 'true')
|
||||||
|
params.append('limit', '50')
|
||||||
|
|
||||||
|
const data = await api.request<Listing[]>(`/listings?${params.toString()}`)
|
||||||
|
setListings(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load listings:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredListings = listings.filter(listing => {
|
||||||
|
if (searchQuery && !listing.domain.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (minPrice && listing.asking_price && listing.asking_price < parseFloat(minPrice)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (maxPrice && listing.asking_price && listing.asking_price > parseFloat(maxPrice)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatPrice = (price: number | null, currency: string) => {
|
||||||
|
if (!price) return 'Make Offer'
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 80) return 'text-accent bg-accent/10'
|
||||||
|
if (score >= 60) return 'text-amber-400 bg-amber-500/10'
|
||||||
|
return 'text-foreground-muted bg-foreground/5'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
|
{/* Background Effects */}
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{/* Hero Header */}
|
||||||
|
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||||
|
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Marketplace</span>
|
||||||
|
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||||
|
Premium Domains. Direct.
|
||||||
|
</h1>
|
||||||
|
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||||
|
Browse verified domains from trusted sellers. No middlemen, no hassle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Filters */}
|
||||||
|
<div className="mb-8 animate-slide-up">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search domains..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-10 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||||
|
text-body text-foreground placeholder:text-foreground-subtle
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent
|
||||||
|
transition-all duration-300"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 p-1 text-foreground-subtle hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Min"
|
||||||
|
value={minPrice}
|
||||||
|
onChange={(e) => setMinPrice(e.target.value)}
|
||||||
|
className="w-24 pl-9 pr-2 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||||
|
text-body text-foreground placeholder:text-foreground-subtle
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Max"
|
||||||
|
value={maxPrice}
|
||||||
|
onChange={(e) => setMaxPrice(e.target.value)}
|
||||||
|
className="w-24 pl-9 pr-2 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||||
|
text-body text-foreground placeholder:text-foreground-subtle
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setVerifiedOnly(!verifiedOnly)}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-2 px-4 py-3 rounded-xl border transition-all",
|
||||||
|
verifiedOnly
|
||||||
|
? "bg-accent text-background border-accent"
|
||||||
|
: "bg-background-secondary/50 text-foreground-muted border-border hover:border-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Verified Only
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||||
|
className="px-4 py-3 bg-background-secondary/50 border border-border rounded-xl
|
||||||
|
text-body text-foreground cursor-pointer focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||||
|
>
|
||||||
|
<option value="newest">Newest First</option>
|
||||||
|
<option value="price_asc">Price: Low to High</option>
|
||||||
|
<option value="price_desc">Price: High to Low</option>
|
||||||
|
<option value="popular">Most Viewed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Listings Grid */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, idx) => (
|
||||||
|
<div key={idx} className="animate-pulse p-6 bg-background-secondary/30 border border-border rounded-2xl">
|
||||||
|
<div className="h-8 w-40 bg-background-tertiary rounded mb-4" />
|
||||||
|
<div className="h-4 w-24 bg-background-tertiary rounded mb-6" />
|
||||||
|
<div className="h-10 w-full bg-background-tertiary rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : filteredListings.length === 0 ? (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||||
|
<h2 className="text-xl font-medium text-foreground mb-2">No Listings Found</h2>
|
||||||
|
<p className="text-foreground-muted mb-8">
|
||||||
|
{searchQuery
|
||||||
|
? `No domains match "${searchQuery}"`
|
||||||
|
: 'Be the first to list your domain!'}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/command/listings"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-5 h-5" />
|
||||||
|
List Your Domain
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 animate-slide-up">
|
||||||
|
{filteredListings.map((listing) => (
|
||||||
|
<Link
|
||||||
|
key={listing.slug}
|
||||||
|
href={`/buy/${listing.slug}`}
|
||||||
|
className="group p-6 bg-background-secondary/30 border border-border rounded-2xl
|
||||||
|
hover:border-accent/50 hover:bg-background-secondary/50 transition-all duration-300"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-mono text-lg font-medium text-foreground group-hover:text-accent transition-colors truncate">
|
||||||
|
{listing.domain}
|
||||||
|
</h3>
|
||||||
|
{listing.title && (
|
||||||
|
<p className="text-sm text-foreground-muted truncate mt-1">{listing.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{listing.is_verified && (
|
||||||
|
<div className="shrink-0 ml-2 w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||||
|
<Shield className="w-4 h-4 text-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score & Price */}
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
{listing.pounce_score && (
|
||||||
|
<div className={clsx("px-2 py-1 rounded text-sm font-medium", getScoreColor(listing.pounce_score))}>
|
||||||
|
Score: {listing.pounce_score}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-display text-foreground">
|
||||||
|
{formatPrice(listing.asking_price, listing.currency)}
|
||||||
|
</p>
|
||||||
|
{listing.price_type === 'negotiable' && (
|
||||||
|
<p className="text-xs text-accent">Negotiable</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View CTA */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-border/50 flex items-center justify-between">
|
||||||
|
<span className="text-sm text-foreground-muted flex items-center gap-1">
|
||||||
|
<Eye className="w-3 h-3" />
|
||||||
|
View Details
|
||||||
|
</span>
|
||||||
|
<ExternalLink className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA for Sellers */}
|
||||||
|
<div className="mt-16 p-8 bg-gradient-to-r from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center animate-slide-up">
|
||||||
|
<Sparkles className="w-10 h-10 text-accent mx-auto mb-4" />
|
||||||
|
<h2 className="text-2xl font-display text-foreground mb-2">Got a domain to sell?</h2>
|
||||||
|
<p className="text-foreground-muted mb-6 max-w-xl mx-auto">
|
||||||
|
List your domain on Pounce and reach serious buyers.
|
||||||
|
DNS verification ensures only real owners can list.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/command/listings"
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
List Your Domain
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
598
frontend/src/app/command/alerts/page.tsx
Normal file
598
frontend/src/app/command/alerts/page.tsx
Normal file
@ -0,0 +1,598 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useStore } from '@/lib/store'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
|
import { PageContainer, StatCard, Badge } from '@/components/PremiumTable'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Bell,
|
||||||
|
Target,
|
||||||
|
Zap,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
Edit2,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
X,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Mail,
|
||||||
|
Smartphone,
|
||||||
|
Settings,
|
||||||
|
TestTube,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface SniperAlert {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
tlds: string | null
|
||||||
|
keywords: string | null
|
||||||
|
exclude_keywords: string | null
|
||||||
|
max_length: number | null
|
||||||
|
min_length: number | null
|
||||||
|
max_price: number | null
|
||||||
|
min_price: number | null
|
||||||
|
max_bids: number | null
|
||||||
|
ending_within_hours: number | null
|
||||||
|
platforms: string | null
|
||||||
|
no_numbers: boolean
|
||||||
|
no_hyphens: boolean
|
||||||
|
exclude_chars: string | null
|
||||||
|
notify_email: boolean
|
||||||
|
notify_sms: boolean
|
||||||
|
is_active: boolean
|
||||||
|
matches_count: number
|
||||||
|
notifications_sent: number
|
||||||
|
last_matched_at: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestResult {
|
||||||
|
alert_name: string
|
||||||
|
auctions_checked: number
|
||||||
|
matches_found: number
|
||||||
|
matches: Array<{
|
||||||
|
domain: string
|
||||||
|
platform: string
|
||||||
|
current_bid: number
|
||||||
|
num_bids: number
|
||||||
|
end_time: string
|
||||||
|
}>
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SniperAlertsPage() {
|
||||||
|
const { subscription } = useStore()
|
||||||
|
|
||||||
|
const [alerts, setAlerts] = useState<SniperAlert[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [testing, setTesting] = useState<number | null>(null)
|
||||||
|
const [testResult, setTestResult] = useState<TestResult | null>(null)
|
||||||
|
const [expandedAlert, setExpandedAlert] = useState<number | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
const [newAlert, setNewAlert] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
tlds: '',
|
||||||
|
keywords: '',
|
||||||
|
exclude_keywords: '',
|
||||||
|
max_length: '',
|
||||||
|
min_length: '',
|
||||||
|
max_price: '',
|
||||||
|
min_price: '',
|
||||||
|
max_bids: '',
|
||||||
|
no_numbers: false,
|
||||||
|
no_hyphens: false,
|
||||||
|
exclude_chars: '',
|
||||||
|
notify_email: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAlerts()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadAlerts = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.request<SniperAlert[]>('/sniper-alerts')
|
||||||
|
setAlerts(data)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreating(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.request('/sniper-alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newAlert.name,
|
||||||
|
description: newAlert.description || null,
|
||||||
|
tlds: newAlert.tlds || null,
|
||||||
|
keywords: newAlert.keywords || null,
|
||||||
|
exclude_keywords: newAlert.exclude_keywords || null,
|
||||||
|
max_length: newAlert.max_length ? parseInt(newAlert.max_length) : null,
|
||||||
|
min_length: newAlert.min_length ? parseInt(newAlert.min_length) : null,
|
||||||
|
max_price: newAlert.max_price ? parseFloat(newAlert.max_price) : null,
|
||||||
|
min_price: newAlert.min_price ? parseFloat(newAlert.min_price) : null,
|
||||||
|
max_bids: newAlert.max_bids ? parseInt(newAlert.max_bids) : null,
|
||||||
|
no_numbers: newAlert.no_numbers,
|
||||||
|
no_hyphens: newAlert.no_hyphens,
|
||||||
|
exclude_chars: newAlert.exclude_chars || null,
|
||||||
|
notify_email: newAlert.notify_email,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
setSuccess('Sniper Alert created!')
|
||||||
|
setShowCreateModal(false)
|
||||||
|
setNewAlert({
|
||||||
|
name: '', description: '', tlds: '', keywords: '', exclude_keywords: '',
|
||||||
|
max_length: '', min_length: '', max_price: '', min_price: '', max_bids: '',
|
||||||
|
no_numbers: false, no_hyphens: false, exclude_chars: '', notify_email: true,
|
||||||
|
})
|
||||||
|
loadAlerts()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (alert: SniperAlert) => {
|
||||||
|
try {
|
||||||
|
await api.request(`/sniper-alerts/${alert.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ is_active: !alert.is_active }),
|
||||||
|
})
|
||||||
|
loadAlerts()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (alert: SniperAlert) => {
|
||||||
|
if (!confirm(`Delete alert "${alert.name}"?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.request(`/sniper-alerts/${alert.id}`, { method: 'DELETE' })
|
||||||
|
setSuccess('Alert deleted')
|
||||||
|
loadAlerts()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTest = async (alert: SniperAlert) => {
|
||||||
|
setTesting(alert.id)
|
||||||
|
setTestResult(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.request<TestResult>(`/sniper-alerts/${alert.id}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
setTestResult(result)
|
||||||
|
setExpandedAlert(alert.id)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setTesting(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = subscription?.tier || 'scout'
|
||||||
|
const limits = { scout: 2, trader: 10, tycoon: 50 }
|
||||||
|
const maxAlerts = limits[tier as keyof typeof limits] || 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandCenterLayout
|
||||||
|
title="Sniper Alerts"
|
||||||
|
subtitle={`Hyper-personalized auction notifications (${alerts.length}/${maxAlerts})`}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
disabled={alerts.length >= maxAlerts}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg
|
||||||
|
hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Alert
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PageContainer>
|
||||||
|
{/* Messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||||
|
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-accent" />
|
||||||
|
<p className="text-sm text-accent flex-1">{success}</p>
|
||||||
|
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard title="Active Alerts" value={alerts.filter(a => a.is_active).length} icon={Bell} accent />
|
||||||
|
<StatCard title="Total Matches" value={alerts.reduce((sum, a) => sum + a.matches_count, 0)} icon={Target} />
|
||||||
|
<StatCard title="Notifications Sent" value={alerts.reduce((sum, a) => sum + a.notifications_sent, 0)} icon={Zap} />
|
||||||
|
<StatCard title="Alert Slots" value={`${alerts.length}/${maxAlerts}`} icon={Settings} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : alerts.length === 0 ? (
|
||||||
|
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
|
||||||
|
<Target className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||||
|
<h2 className="text-xl font-medium text-foreground mb-2">No Sniper Alerts</h2>
|
||||||
|
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
|
||||||
|
Create alerts to get notified when domains matching your criteria appear in auctions.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Create Alert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<div
|
||||||
|
key={alert.id}
|
||||||
|
className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden transition-all hover:border-border-hover"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="text-lg font-medium text-foreground">{alert.name}</h3>
|
||||||
|
<Badge variant={alert.is_active ? 'success' : 'default'}>
|
||||||
|
{alert.is_active ? 'Active' : 'Paused'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{alert.description && (
|
||||||
|
<p className="text-sm text-foreground-muted">{alert.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-6 text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-display text-foreground">{alert.matches_count}</p>
|
||||||
|
<p className="text-xs text-foreground-muted">Matches</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg font-display text-foreground">{alert.notifications_sent}</p>
|
||||||
|
<p className="text-xs text-foreground-muted">Notified</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTest(alert)}
|
||||||
|
disabled={testing === alert.id}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
|
||||||
|
>
|
||||||
|
{testing === alert.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(alert)}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg transition-all",
|
||||||
|
alert.is_active
|
||||||
|
? "bg-amber-500/10 text-amber-400 hover:bg-amber-500/20"
|
||||||
|
: "bg-accent/10 text-accent hover:bg-accent/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{alert.is_active ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
|
{alert.is_active ? 'Pause' : 'Activate'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(alert)}
|
||||||
|
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedAlert(expandedAlert === alert.id ? null : alert.id)}
|
||||||
|
className="p-2 text-foreground-subtle hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{expandedAlert === alert.id ? (
|
||||||
|
<ChevronUp className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Summary */}
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{alert.tlds && (
|
||||||
|
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||||
|
TLDs: {alert.tlds}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{alert.max_length && (
|
||||||
|
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||||
|
Max {alert.max_length} chars
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{alert.max_price && (
|
||||||
|
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||||
|
Max ${alert.max_price}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{alert.no_numbers && (
|
||||||
|
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||||
|
No numbers
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{alert.no_hyphens && (
|
||||||
|
<span className="px-2 py-1 bg-foreground/5 text-foreground-muted text-xs rounded">
|
||||||
|
No hyphens
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{alert.notify_email && (
|
||||||
|
<span className="px-2 py-1 bg-accent/10 text-accent text-xs rounded flex items-center gap-1">
|
||||||
|
<Mail className="w-3 h-3" /> Email
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Results */}
|
||||||
|
{expandedAlert === alert.id && testResult && testResult.alert_name === alert.name && (
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<div className="p-4 bg-background rounded-xl border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="text-sm font-medium text-foreground">Test Results</p>
|
||||||
|
<p className="text-xs text-foreground-muted">
|
||||||
|
Checked {testResult.auctions_checked} auctions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult.matches_found === 0 ? (
|
||||||
|
<p className="text-sm text-foreground-muted">{testResult.message}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-accent">
|
||||||
|
Found {testResult.matches_found} matching domains!
|
||||||
|
</p>
|
||||||
|
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||||
|
{testResult.matches.map((match, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between text-sm py-1">
|
||||||
|
<span className="font-mono text-foreground">{match.domain}</span>
|
||||||
|
<span className="text-foreground-muted">${match.current_bid}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm overflow-y-auto">
|
||||||
|
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6 my-8">
|
||||||
|
<h2 className="text-xl font-display text-foreground mb-2">Create Sniper Alert</h2>
|
||||||
|
<p className="text-sm text-foreground-muted mb-6">
|
||||||
|
Get notified when domains matching your criteria appear in auctions.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4 max-h-[60vh] overflow-y-auto pr-2">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Alert Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={newAlert.name}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, name: e.target.value })}
|
||||||
|
placeholder="4-letter .com without numbers"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newAlert.description}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, description: e.target.value })}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">TLDs (comma-separated)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newAlert.tlds}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, tlds: e.target.value })}
|
||||||
|
placeholder="com,io,ai"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Keywords (must contain)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newAlert.keywords}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, keywords: e.target.value })}
|
||||||
|
placeholder="ai,tech,crypto"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Min Length</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="63"
|
||||||
|
value={newAlert.min_length}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, min_length: e.target.value })}
|
||||||
|
placeholder="3"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Max Length</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="63"
|
||||||
|
value={newAlert.max_length}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, max_length: e.target.value })}
|
||||||
|
placeholder="6"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Max Price ($)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={newAlert.max_price}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, max_price: e.target.value })}
|
||||||
|
placeholder="500"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Max Bids (low competition)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={newAlert.max_bids}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, max_bids: e.target.value })}
|
||||||
|
placeholder="5"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Exclude Characters</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newAlert.exclude_chars}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, exclude_chars: e.target.value })}
|
||||||
|
placeholder="q,x,z"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newAlert.no_numbers}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, no_numbers: e.target.checked })}
|
||||||
|
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">No numbers</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newAlert.no_hyphens}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, no_hyphens: e.target.checked })}
|
||||||
|
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">No hyphens</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newAlert.notify_email}
|
||||||
|
onChange={(e) => setNewAlert({ ...newAlert, notify_email: e.target.checked })}
|
||||||
|
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground flex items-center gap-1">
|
||||||
|
<Mail className="w-4 h-4" /> Email alerts
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !newAlert.name}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Target className="w-5 h-5" />}
|
||||||
|
{creating ? 'Creating...' : 'Create Alert'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandCenterLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
561
frontend/src/app/command/listings/page.tsx
Normal file
561
frontend/src/app/command/listings/page.tsx
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useStore } from '@/lib/store'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { CommandCenterLayout } from '@/components/CommandCenterLayout'
|
||||||
|
import { PageContainer, StatCard, PremiumTable, Badge } from '@/components/PremiumTable'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Shield,
|
||||||
|
Eye,
|
||||||
|
MessageSquare,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
Edit2,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
Copy,
|
||||||
|
RefreshCw,
|
||||||
|
DollarSign,
|
||||||
|
Globe,
|
||||||
|
X,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
interface Listing {
|
||||||
|
id: number
|
||||||
|
domain: string
|
||||||
|
slug: string
|
||||||
|
title: string | null
|
||||||
|
description: string | null
|
||||||
|
asking_price: number | null
|
||||||
|
min_offer: number | null
|
||||||
|
currency: string
|
||||||
|
price_type: string
|
||||||
|
pounce_score: number | null
|
||||||
|
estimated_value: number | null
|
||||||
|
verification_status: string
|
||||||
|
is_verified: boolean
|
||||||
|
status: string
|
||||||
|
show_valuation: boolean
|
||||||
|
allow_offers: boolean
|
||||||
|
view_count: number
|
||||||
|
inquiry_count: number
|
||||||
|
public_url: string
|
||||||
|
created_at: string
|
||||||
|
published_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerificationInfo {
|
||||||
|
verification_code: string
|
||||||
|
dns_record_type: string
|
||||||
|
dns_record_name: string
|
||||||
|
dns_record_value: string
|
||||||
|
instructions: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListingsPage() {
|
||||||
|
const { subscription } = useStore()
|
||||||
|
|
||||||
|
const [listings, setListings] = useState<Listing[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [showVerifyModal, setShowVerifyModal] = useState(false)
|
||||||
|
const [selectedListing, setSelectedListing] = useState<Listing | null>(null)
|
||||||
|
const [verificationInfo, setVerificationInfo] = useState<VerificationInfo | null>(null)
|
||||||
|
const [verifying, setVerifying] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Create form
|
||||||
|
const [newListing, setNewListing] = useState({
|
||||||
|
domain: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
asking_price: '',
|
||||||
|
price_type: 'negotiable',
|
||||||
|
allow_offers: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadListings()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadListings = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.request<Listing[]>('/listings/my')
|
||||||
|
setListings(data)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCreating(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.request('/listings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
domain: newListing.domain,
|
||||||
|
title: newListing.title || null,
|
||||||
|
description: newListing.description || null,
|
||||||
|
asking_price: newListing.asking_price ? parseFloat(newListing.asking_price) : null,
|
||||||
|
price_type: newListing.price_type,
|
||||||
|
allow_offers: newListing.allow_offers,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
setSuccess('Listing created! Now verify ownership to publish.')
|
||||||
|
setShowCreateModal(false)
|
||||||
|
setNewListing({ domain: '', title: '', description: '', asking_price: '', price_type: 'negotiable', allow_offers: true })
|
||||||
|
loadListings()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartVerification = async (listing: Listing) => {
|
||||||
|
setSelectedListing(listing)
|
||||||
|
setVerifying(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await api.request<VerificationInfo>(`/listings/${listing.id}/verify-dns`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
setVerificationInfo(info)
|
||||||
|
setShowVerifyModal(true)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setVerifying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckVerification = async () => {
|
||||||
|
if (!selectedListing) return
|
||||||
|
setVerifying(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.request<{ verified: boolean; message: string }>(
|
||||||
|
`/listings/${selectedListing.id}/verify-dns/check`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.verified) {
|
||||||
|
setSuccess('Domain verified! You can now publish your listing.')
|
||||||
|
setShowVerifyModal(false)
|
||||||
|
loadListings()
|
||||||
|
} else {
|
||||||
|
setError(result.message)
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setVerifying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePublish = async (listing: Listing) => {
|
||||||
|
try {
|
||||||
|
await api.request(`/listings/${listing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status: 'active' }),
|
||||||
|
})
|
||||||
|
setSuccess('Listing published!')
|
||||||
|
loadListings()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (listing: Listing) => {
|
||||||
|
if (!confirm(`Delete listing for ${listing.domain}?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.request(`/listings/${listing.id}`, { method: 'DELETE' })
|
||||||
|
setSuccess('Listing deleted')
|
||||||
|
loadListings()
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
setSuccess('Copied to clipboard!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPrice = (price: number | null, currency: string) => {
|
||||||
|
if (!price) return 'Make Offer'
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
}).format(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string, isVerified: boolean) => {
|
||||||
|
if (status === 'active') return <Badge variant="success">Live</Badge>
|
||||||
|
if (status === 'draft' && !isVerified) return <Badge variant="warning">Needs Verification</Badge>
|
||||||
|
if (status === 'draft') return <Badge>Draft</Badge>
|
||||||
|
if (status === 'sold') return <Badge variant="accent">Sold</Badge>
|
||||||
|
return <Badge>{status}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = subscription?.tier || 'scout'
|
||||||
|
const limits = { scout: 2, trader: 10, tycoon: 50 }
|
||||||
|
const maxListings = limits[tier as keyof typeof limits] || 2
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandCenterLayout
|
||||||
|
title="My Listings"
|
||||||
|
subtitle={`Manage your domain marketplace listings (${listings.length}/${maxListings})`}
|
||||||
|
actions={
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
disabled={listings.length >= maxListings}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg
|
||||||
|
hover:bg-accent-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Listing
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PageContainer>
|
||||||
|
{/* Messages */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-400" />
|
||||||
|
<p className="text-sm text-red-400 flex-1">{error}</p>
|
||||||
|
<button onClick={() => setError(null)}><X className="w-4 h-4 text-red-400" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<div className="p-4 bg-accent/10 border border-accent/20 rounded-xl flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-accent" />
|
||||||
|
<p className="text-sm text-accent flex-1">{success}</p>
|
||||||
|
<button onClick={() => setSuccess(null)}><X className="w-4 h-4 text-accent" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard title="Total Listings" value={listings.length} icon={Globe} />
|
||||||
|
<StatCard
|
||||||
|
title="Published"
|
||||||
|
value={listings.filter(l => l.status === 'active').length}
|
||||||
|
icon={CheckCircle}
|
||||||
|
accent
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Views"
|
||||||
|
value={listings.reduce((sum, l) => sum + l.view_count, 0)}
|
||||||
|
icon={Eye}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Inquiries"
|
||||||
|
value={listings.reduce((sum, l) => sum + l.inquiry_count, 0)}
|
||||||
|
icon={MessageSquare}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Listings Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-6 h-6 text-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : listings.length === 0 ? (
|
||||||
|
<div className="text-center py-16 bg-background-secondary/30 border border-border rounded-2xl">
|
||||||
|
<Sparkles className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||||
|
<h2 className="text-xl font-medium text-foreground mb-2">No Listings Yet</h2>
|
||||||
|
<p className="text-foreground-muted mb-6 max-w-md mx-auto">
|
||||||
|
Create your first listing to sell a domain on the Pounce marketplace.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
<Plus className="w-5 h-5" />
|
||||||
|
Create Listing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{listings.map((listing) => (
|
||||||
|
<div
|
||||||
|
key={listing.id}
|
||||||
|
className="p-5 bg-background-secondary/30 border border-border rounded-2xl hover:border-border-hover transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
|
{/* Domain Info */}
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h3 className="font-mono text-lg font-medium text-foreground">{listing.domain}</h3>
|
||||||
|
{getStatusBadge(listing.status, listing.is_verified)}
|
||||||
|
{listing.is_verified && (
|
||||||
|
<div className="w-6 h-6 bg-accent/10 rounded flex items-center justify-center" title="Verified">
|
||||||
|
<Shield className="w-3 h-3 text-accent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{listing.title && (
|
||||||
|
<p className="text-sm text-foreground-muted">{listing.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xl font-display text-foreground">
|
||||||
|
{formatPrice(listing.asking_price, listing.currency)}
|
||||||
|
</p>
|
||||||
|
{listing.pounce_score && (
|
||||||
|
<p className="text-xs text-foreground-muted">Score: {listing.pounce_score}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="flex items-center gap-4 text-sm text-foreground-muted">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Eye className="w-4 h-4" /> {listing.view_count}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MessageSquare className="w-4 h-4" /> {listing.inquiry_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!listing.is_verified && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartVerification(listing)}
|
||||||
|
disabled={verifying}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-amber-500/10 text-amber-400 text-sm font-medium rounded-lg hover:bg-amber-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
Verify
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listing.is_verified && listing.status === 'draft' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handlePublish(listing)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listing.status === 'active' && (
|
||||||
|
<Link
|
||||||
|
href={`/buy/${listing.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-foreground/5 text-foreground-muted text-sm font-medium rounded-lg hover:bg-foreground/10 transition-all"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(listing)}
|
||||||
|
className="p-2 text-foreground-subtle hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContainer>
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-lg bg-background-secondary border border-border rounded-2xl p-6">
|
||||||
|
<h2 className="text-xl font-display text-foreground mb-6">Create Listing</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Domain *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={newListing.domain}
|
||||||
|
onChange={(e) => setNewListing({ ...newListing, domain: e.target.value })}
|
||||||
|
placeholder="example.com"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Headline</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newListing.title}
|
||||||
|
onChange={(e) => setNewListing({ ...newListing, title: e.target.value })}
|
||||||
|
placeholder="Perfect for AI startups"
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={newListing.description}
|
||||||
|
onChange={(e) => setNewListing({ ...newListing, description: e.target.value })}
|
||||||
|
placeholder="Tell potential buyers about this domain..."
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Asking Price (USD)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-foreground-subtle" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newListing.asking_price}
|
||||||
|
onChange={(e) => setNewListing({ ...newListing, asking_price: e.target.value })}
|
||||||
|
placeholder="Leave empty for 'Make Offer'"
|
||||||
|
className="w-full pl-9 pr-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-foreground-muted mb-1">Price Type</label>
|
||||||
|
<select
|
||||||
|
value={newListing.price_type}
|
||||||
|
onChange={(e) => setNewListing({ ...newListing, price_type: e.target.value })}
|
||||||
|
className="w-full px-4 py-3 bg-background border border-border rounded-xl text-foreground focus:outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
<option value="negotiable">Negotiable</option>
|
||||||
|
<option value="fixed">Fixed Price</option>
|
||||||
|
<option value="make_offer">Make Offer Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newListing.allow_offers}
|
||||||
|
onChange={(e) => setNewListing({ ...newListing, allow_offers: e.target.checked })}
|
||||||
|
className="w-5 h-5 rounded border-border text-accent focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-foreground">Allow buyers to make offers</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creating ? <Loader2 className="w-5 h-5 animate-spin" /> : <Plus className="w-5 h-5" />}
|
||||||
|
{creating ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Verification Modal */}
|
||||||
|
{showVerifyModal && verificationInfo && selectedListing && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-xl bg-background-secondary border border-border rounded-2xl p-6">
|
||||||
|
<h2 className="text-xl font-display text-foreground mb-2">Verify Domain Ownership</h2>
|
||||||
|
<p className="text-sm text-foreground-muted mb-6">
|
||||||
|
Add a DNS TXT record to prove you own <strong>{selectedListing.domain}</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-background rounded-xl border border-border">
|
||||||
|
<p className="text-sm text-foreground-muted mb-2">Record Type</p>
|
||||||
|
<p className="font-mono text-foreground">{verificationInfo.dns_record_type}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-background rounded-xl border border-border">
|
||||||
|
<p className="text-sm text-foreground-muted mb-2">Name / Host</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-mono text-foreground">{verificationInfo.dns_record_name}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(verificationInfo.dns_record_name)}
|
||||||
|
className="p-2 text-foreground-subtle hover:text-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-background rounded-xl border border-border">
|
||||||
|
<p className="text-sm text-foreground-muted mb-2">Value</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-mono text-sm text-foreground break-all">{verificationInfo.dns_record_value}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(verificationInfo.dns_record_value)}
|
||||||
|
className="p-2 text-foreground-subtle hover:text-accent transition-colors shrink-0"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-accent/5 border border-accent/20 rounded-xl">
|
||||||
|
<p className="text-sm text-foreground-muted whitespace-pre-line">
|
||||||
|
{verificationInfo.instructions}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowVerifyModal(false)}
|
||||||
|
className="flex-1 px-4 py-3 border border-border text-foreground-muted rounded-xl hover:bg-foreground/5 transition-all"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCheckVerification}
|
||||||
|
disabled={verifying}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{verifying ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
|
||||||
|
{verifying ? 'Checking...' : 'Check Verification'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CommandCenterLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Filter,
|
Filter,
|
||||||
Crosshair,
|
Crosshair,
|
||||||
|
Tag,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -369,8 +370,101 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* New Features: For Sale + Sniper Alerts */}
|
||||||
|
<section className="relative py-20 sm:py-28 px-4 sm:px-6 bg-background-secondary/30 border-y border-border/50">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
<div className="text-center max-w-3xl mx-auto mb-12 sm:mb-16">
|
||||||
|
<span className="text-sm font-semibold text-accent uppercase tracking-wider">New Features</span>
|
||||||
|
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
|
||||||
|
More than hunting.
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 lg:gap-8">
|
||||||
|
{/* For Sale Marketplace */}
|
||||||
|
<div className="group relative p-8 sm:p-10 bg-gradient-to-br from-accent/10 to-accent/5
|
||||||
|
border border-accent/30 rounded-3xl hover:border-accent/50 transition-all duration-500">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="w-14 h-14 bg-accent/20 border border-accent/30 rounded-2xl flex items-center justify-center">
|
||||||
|
<Tag className="w-6 h-6 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-display text-foreground mb-1">Sell Your Domains</h3>
|
||||||
|
<p className="text-sm text-accent">Marketplace</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-foreground-muted mb-6 leading-relaxed">
|
||||||
|
Create professional "For Sale" landing pages with one click.
|
||||||
|
<span className="text-foreground"> DNS verification</span> proves ownership.
|
||||||
|
Buyers contact you directly through Pounce.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm mb-6">
|
||||||
|
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||||
|
<Shield className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Verified Owner badge</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||||
|
<BarChart3 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Pounce Score valuation</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||||
|
<Lock className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Secure contact form</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
href="/buy"
|
||||||
|
className="inline-flex items-center gap-2 text-accent hover:text-accent-hover font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Browse Marketplace
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sniper Alerts */}
|
||||||
|
<div className="group relative p-8 sm:p-10 bg-gradient-to-br from-foreground/5 to-foreground/0
|
||||||
|
border border-border rounded-3xl hover:border-accent/30 transition-all duration-500">
|
||||||
|
<div className="flex items-start gap-4 mb-6">
|
||||||
|
<div className="w-14 h-14 bg-foreground/10 border border-border rounded-2xl flex items-center justify-center">
|
||||||
|
<Target className="w-6 h-6 text-foreground" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-display text-foreground mb-1">Sniper Alerts</h3>
|
||||||
|
<p className="text-sm text-foreground-muted">Hyper-Personalized</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-foreground-muted mb-6 leading-relaxed">
|
||||||
|
Ultra-specific filters that notify you <span className="text-foreground">exactly</span> when matching domains appear.
|
||||||
|
"4-letter .com under $500 without numbers" — we've got you.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2 text-sm mb-6">
|
||||||
|
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||||
|
<Filter className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Custom TLD, length, price filters</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||||
|
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Email & SMS alerts</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-3 text-foreground-subtle">
|
||||||
|
<Zap className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Real-time auction matching</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Link
|
||||||
|
href="/command/alerts"
|
||||||
|
className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Set Up Alerts
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Trending TLDs Section */}
|
{/* Trending TLDs Section */}
|
||||||
<section className="relative py-20 sm:py-28 px-4 sm:px-6 bg-background-secondary/30">
|
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-10 sm:mb-14">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-10 sm:mb-14">
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Tag,
|
||||||
|
Target,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@ -101,6 +103,18 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
|||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
badge: null,
|
badge: null,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/command/listings',
|
||||||
|
label: 'For Sale',
|
||||||
|
icon: Tag,
|
||||||
|
badge: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/command/alerts',
|
||||||
|
label: 'Sniper Alerts',
|
||||||
|
icon: Target,
|
||||||
|
badge: null,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const bottomItems = [
|
const bottomItems = [
|
||||||
|
|||||||
Reference in New Issue
Block a user