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:
yves.gugger
2025-12-10 11:44:56 +01:00
parent 10bd7e4d98
commit 18d50e96f4
14 changed files with 3960 additions and 1 deletions

246
DATABASE_MIGRATIONS.md Normal file
View 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 |

View File

@ -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
View 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.",
}

View 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.",
}

View File

@ -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",
]

View 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}>"

View 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}>"

View File

@ -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}>"

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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">

View File

@ -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 = [