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.price_alerts import router as price_alerts_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()
|
||||
|
||||
@ -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(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
|
||||
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.admin_log import AdminActivityLog
|
||||
from app.models.blog import BlogPost
|
||||
from app.models.listing import DomainListing, ListingInquiry, ListingView
|
||||
from app.models.sniper_alert import SniperAlert, SniperAlertMatch
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -25,4 +27,11 @@ __all__ = [
|
||||
"PriceAlert",
|
||||
"AdminActivityLog",
|
||||
"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(
|
||||
"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:
|
||||
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,
|
||||
Filter,
|
||||
Crosshair,
|
||||
Tag,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import clsx from 'clsx'
|
||||
@ -369,8 +370,101 @@ export default function HomePage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<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">
|
||||
{/* Section Header */}
|
||||
<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,
|
||||
X,
|
||||
Sparkles,
|
||||
Tag,
|
||||
Target,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
@ -101,6 +103,18 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S
|
||||
icon: TrendingUp,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/listings',
|
||||
label: 'For Sale',
|
||||
icon: Tag,
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
href: '/command/alerts',
|
||||
label: 'Sniper Alerts',
|
||||
icon: Target,
|
||||
badge: null,
|
||||
},
|
||||
]
|
||||
|
||||
const bottomItems = [
|
||||
|
||||
Reference in New Issue
Block a user