Compare commits

...

2 Commits

Author SHA1 Message Date
339e89e65d docs: Update README with Blog, OAuth, and Admin features
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled
2025-12-09 16:54:14 +01:00
cff0ba0984 feat: Add Admin Panel enhancements, Blog system, and OAuth
Admin Panel:
- User Detail Modal with full profile info
- Bulk tier upgrade for multiple users
- User export to CSV
- Price Alerts overview tab
- Domain Health Check trigger
- Email Test functionality
- Scheduler Status with job info and last runs
- Activity Log for admin actions
- Blog management tab with CRUD

Blog System:
- BlogPost model with full content management
- Public API: list, featured, categories, single post
- Admin API: create, update, delete, publish/unpublish
- Frontend blog listing page with categories
- Frontend blog detail page with styling
- View count tracking

OAuth:
- Google OAuth integration
- GitHub OAuth integration
- OAuth callback handling
- Provider selection on login/register

Other improvements:
- Domain checker with check_all_domains function
- Admin activity logging
- Breadcrumbs component
- Toast notification component
- Various UI/UX improvements
2025-12-09 16:52:54 +01:00
37 changed files with 6175 additions and 2349 deletions

View File

@ -121,12 +121,32 @@ npm run dev
### Security Features (v1.1) ### Security Features (v1.1)
- **Password Reset** Secure token-based password recovery via email - **Password Reset** Secure token-based password recovery via email
- **Email Verification** Optional email confirmation for new accounts - **Email Verification** Optional email confirmation for new accounts
- **OAuth Login** Sign in with Google or GitHub
- **Rate Limiting** Protection against brute-force attacks (slowapi) - **Rate Limiting** Protection against brute-force attacks (slowapi)
- **Stripe Payments** Secure subscription payments with Stripe Checkout - **Stripe Payments** Secure subscription payments with Stripe Checkout
- **Stripe Customer Portal** Manage billing, view invoices, cancel subscriptions - **Stripe Customer Portal** Manage billing, view invoices, cancel subscriptions
- **Contact Form** With email confirmation and spam protection - **Contact Form** With email confirmation and spam protection
- **Newsletter** Subscribe/unsubscribe with double opt-in - **Newsletter** Subscribe/unsubscribe with double opt-in
### Blog System (v1.3)
- **Blog Posts** Create and manage blog articles
- **Categories & Tags** Organize content
- **Featured Posts** Highlight posts on homepage
- **View Tracking** Analytics for post views
- **SEO Metadata** Custom meta titles and descriptions
- **Draft/Publish** Content workflow management
### Admin Panel (v1.3)
- **User Management** List, search, upgrade, and manage users
- **Bulk Operations** Upgrade multiple users at once
- **User Export** Export all users to CSV
- **Price Alerts** View all active TLD price alerts
- **Domain Health** Manually trigger domain checks
- **Scheduler Status** Monitor background jobs and last runs
- **Email Test** Verify SMTP configuration
- **Activity Log** Track admin actions
- **Blog Management** Full CRUD for blog posts
### CI/CD Pipeline (v1.2) ### CI/CD Pipeline (v1.2)
- **GitHub Actions** Automated CI/CD on push to main - **GitHub Actions** Automated CI/CD on push to main
- **Frontend Lint** ESLint + TypeScript type checking - **Frontend Lint** ESLint + TypeScript type checking
@ -266,7 +286,9 @@ pounce/
| `/auctions` | Smart Pounce auction aggregator | No* | | `/auctions` | Smart Pounce auction aggregator | No* |
| `/contact` | Contact form | No | | `/contact` | Contact form | No |
| `/about` | About us | No | | `/about` | About us | No |
| `/blog` | Blog & Newsletter signup | No | | `/blog` | Blog posts listing | No |
| `/blog/{slug}` | Blog post detail | No |
| `/admin` | Admin panel | Yes (Admin) |
| `/privacy` | Privacy policy | No | | `/privacy` | Privacy policy | No |
| `/terms` | Terms of service | No | | `/terms` | Terms of service | No |
| `/imprint` | Legal imprint | No | | `/imprint` | Legal imprint | No |
@ -525,13 +547,51 @@ This ensures identical prices on:
| GET | `/api/v1/auctions/opportunities` | AI-powered opportunities (auth) | | GET | `/api/v1/auctions/opportunities` | AI-powered opportunities (auth) |
| GET | `/api/v1/auctions/stats` | Platform statistics | | GET | `/api/v1/auctions/stats` | Platform statistics |
### OAuth
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/oauth/providers` | List enabled OAuth providers |
| GET | `/api/v1/oauth/google/login` | Initiate Google OAuth |
| GET | `/api/v1/oauth/google/callback` | Handle Google callback |
| GET | `/api/v1/oauth/github/login` | Initiate GitHub OAuth |
| GET | `/api/v1/oauth/github/callback` | Handle GitHub callback |
### Blog
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/blog/posts` | List published posts |
| GET | `/api/v1/blog/posts/featured` | Get featured posts |
| GET | `/api/v1/blog/posts/categories` | Get all categories |
| GET | `/api/v1/blog/posts/{slug}` | Get single post |
| GET | `/api/v1/blog/admin/posts` | Admin: List all posts |
| POST | `/api/v1/blog/admin/posts` | Admin: Create post |
| GET | `/api/v1/blog/admin/posts/{id}` | Admin: Get post |
| PATCH | `/api/v1/blog/admin/posts/{id}` | Admin: Update post |
| DELETE | `/api/v1/blog/admin/posts/{id}` | Admin: Delete post |
| POST | `/api/v1/blog/admin/posts/{id}/publish` | Admin: Publish post |
| POST | `/api/v1/blog/admin/posts/{id}/unpublish` | Admin: Unpublish post |
### Admin ### Admin
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| GET | `/api/v1/admin/stats` | Get platform statistics |
| GET | `/api/v1/admin/users` | List all users | | GET | `/api/v1/admin/users` | List all users |
| POST | `/api/v1/admin/upgrade-user` | Upgrade user subscription | | GET | `/api/v1/admin/users/export` | Export users as CSV |
| POST | `/api/v1/admin/scrape-tld-prices` | Manually trigger TLD price scrape | | POST | `/api/v1/admin/users/bulk-upgrade` | Bulk upgrade users |
| GET | `/api/v1/admin/tld-prices/stats` | Get TLD price database stats | | GET | `/api/v1/admin/users/{id}` | Get user details |
| PATCH | `/api/v1/admin/users/{id}` | Update user |
| DELETE | `/api/v1/admin/users/{id}` | Delete user |
| POST | `/api/v1/admin/users/{id}/upgrade` | Upgrade single user |
| GET | `/api/v1/admin/price-alerts` | List all price alerts |
| POST | `/api/v1/admin/domains/check-all` | Trigger domain checks |
| GET | `/api/v1/admin/newsletter` | List newsletter subs |
| GET | `/api/v1/admin/newsletter/export` | Export newsletter |
| POST | `/api/v1/admin/scrape-tld-prices` | Trigger TLD scrape |
| GET | `/api/v1/admin/tld-prices/stats` | Get TLD stats |
| GET | `/api/v1/admin/system/health` | System health check |
| GET | `/api/v1/admin/system/scheduler` | Scheduler status |
| POST | `/api/v1/admin/system/test-email` | Send test email |
| GET | `/api/v1/admin/activity-log` | Get activity log |
--- ---

View File

@ -2,6 +2,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.oauth import router as oauth_router
from app.api.domains import router as domains_router from app.api.domains import router as domains_router
from app.api.check import router as check_router from app.api.check import router as check_router
from app.api.subscription import router as subscription_router from app.api.subscription import router as subscription_router
@ -12,11 +13,13 @@ from app.api.auctions import router as auctions_router
from app.api.webhooks import router as webhooks_router from app.api.webhooks import router as webhooks_router
from app.api.contact import router as contact_router from app.api.contact import router as contact_router
from app.api.price_alerts import router as price_alerts_router from app.api.price_alerts import router as price_alerts_router
from app.api.blog import router as blog_router
api_router = APIRouter() api_router = APIRouter()
# Core API endpoints # Core API endpoints
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"]) api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
api_router.include_router(oauth_router, prefix="/oauth", tags=["OAuth"])
api_router.include_router(check_router, prefix="/check", tags=["Domain Check"]) api_router.include_router(check_router, prefix="/check", tags=["Domain Check"])
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"]) api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
api_router.include_router(subscription_router, prefix="/subscription", tags=["Subscription"]) api_router.include_router(subscription_router, prefix="/subscription", tags=["Subscription"])
@ -31,5 +34,8 @@ api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Ne
# Webhooks (external service callbacks) # Webhooks (external service callbacks)
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"]) api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
# Content
api_router.include_router(blog_router, prefix="/blog", tags=["Blog"])
# Admin endpoints # Admin endpoints
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"]) api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])

View File

@ -226,6 +226,65 @@ async def list_users(
} }
# ============== User Export ==============
# NOTE: This must come BEFORE /users/{user_id} to avoid route conflict
@router.get("/users/export")
async def export_users_csv(
db: Database,
admin: User = Depends(require_admin),
):
"""Export all users as CSV data."""
import csv
import io
result = await db.execute(select(User).order_by(User.created_at))
users_list = result.scalars().all()
# Create CSV
output = io.StringIO()
writer = csv.writer(output)
# Header
writer.writerow([
"ID", "Email", "Name", "Active", "Verified", "Admin",
"Created At", "Last Login", "Tier", "Domain Limit", "Domains Used"
])
for user in users_list:
# Get subscription
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = sub_result.scalar_one_or_none()
# Get domain count
domain_count = await db.execute(
select(func.count(Domain.id)).where(Domain.user_id == user.id)
)
domain_count = domain_count.scalar()
writer.writerow([
user.id,
user.email,
user.name or "",
"Yes" if user.is_active else "No",
"Yes" if user.is_verified else "No",
"Yes" if user.is_admin else "No",
user.created_at.strftime("%Y-%m-%d %H:%M"),
user.last_login.strftime("%Y-%m-%d %H:%M") if user.last_login else "",
subscription.tier.value if subscription else "scout",
subscription.domain_limit if subscription else 5,
domain_count,
])
return {
"csv": output.getvalue(),
"count": len(users_list),
"exported_at": datetime.utcnow().isoformat(),
}
@router.get("/users/{user_id}") @router.get("/users/{user_id}")
async def get_user( async def get_user(
user_id: int, user_id: int,
@ -574,3 +633,329 @@ async def make_user_admin(
await db.commit() await db.commit()
return {"message": f"User {email} is now an admin"} return {"message": f"User {email} is now an admin"}
# ============== Price Alerts ==============
@router.get("/price-alerts")
async def list_price_alerts(
db: Database,
admin: User = Depends(require_admin),
limit: int = 100,
offset: int = 0,
):
"""List all active price alerts with user info."""
query = (
select(PriceAlert, User)
.join(User, PriceAlert.user_id == User.id)
.where(PriceAlert.is_active == True)
.order_by(desc(PriceAlert.created_at))
.offset(offset)
.limit(limit)
)
result = await db.execute(query)
alerts = result.all()
# Total count
count_query = select(func.count(PriceAlert.id)).where(PriceAlert.is_active == True)
total = await db.execute(count_query)
total = total.scalar()
return {
"alerts": [
{
"id": alert.id,
"tld": alert.tld,
"target_price": float(alert.target_price) if alert.target_price else None,
"alert_type": alert.alert_type,
"created_at": alert.created_at.isoformat(),
"user": {
"id": user.id,
"email": user.email,
"name": user.name,
}
}
for alert, user in alerts
],
"total": total,
}
# ============== Domain Health ==============
@router.post("/domains/check-all")
async def trigger_domain_checks(
background_tasks: BackgroundTasks,
db: Database,
admin: User = Depends(require_admin),
):
"""Manually trigger domain availability checks for all watched domains."""
from app.services.domain_checker import check_all_domains
# Count domains to check
total_domains = await db.execute(select(func.count(Domain.id)))
total_domains = total_domains.scalar()
if total_domains == 0:
return {"message": "No domains to check", "domains_queued": 0}
# Run in background
background_tasks.add_task(check_all_domains, db)
return {
"message": "Domain checks started",
"domains_queued": total_domains,
"started_at": datetime.utcnow().isoformat(),
}
# ============== Email Test ==============
@router.post("/system/test-email")
async def test_email(
db: Database,
admin: User = Depends(require_admin),
):
"""Send a test email to the admin user."""
from app.services.email_service import email_service
if not email_service.is_configured:
raise HTTPException(
status_code=400,
detail="Email service is not configured. Check SMTP settings."
)
try:
await email_service.send_email(
to_email=admin.email,
subject="pounce Admin Panel - Test Email",
html_content=f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #22c55e;">✅ Email Test Successful</h1>
<p>This is a test email from the pounce Admin Panel.</p>
<p>If you received this, your SMTP configuration is working correctly.</p>
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">
Sent at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}<br>
Admin: {admin.email}
</p>
</div>
""",
text_content=f"Email Test Successful\n\nThis is a test email from the pounce Admin Panel.\nSent at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}"
)
return {
"message": "Test email sent successfully",
"sent_to": admin.email,
"timestamp": datetime.utcnow().isoformat(),
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to send email: {str(e)}"
)
# ============== Scheduler Status ==============
@router.get("/system/scheduler")
async def get_scheduler_status(
db: Database,
admin: User = Depends(require_admin),
):
"""Get scheduler job status and last run times."""
from app.scheduler import scheduler
jobs = []
for job in scheduler.get_jobs():
jobs.append({
"id": job.id,
"name": job.name,
"next_run": job.next_run_time.isoformat() if job.next_run_time else None,
"trigger": str(job.trigger),
})
# Get last scrape times from database
# TLD scrape - check latest TLD price record
latest_tld = await db.execute(
select(TLDPrice.recorded_at).order_by(desc(TLDPrice.recorded_at)).limit(1)
)
latest_tld = latest_tld.scalar()
# Auction scrape - check latest auction record
latest_auction = await db.execute(
select(DomainAuction.scraped_at).order_by(desc(DomainAuction.scraped_at)).limit(1)
)
latest_auction = latest_auction.scalar()
# Domain check - check latest domain check (via Domain.last_checked)
latest_domain_check = await db.execute(
select(Domain.last_checked).where(Domain.last_checked.isnot(None)).order_by(desc(Domain.last_checked)).limit(1)
)
latest_domain_check = latest_domain_check.scalar()
return {
"scheduler_running": scheduler.running,
"jobs": jobs,
"last_runs": {
"tld_scrape": latest_tld.isoformat() if latest_tld else None,
"auction_scrape": latest_auction.isoformat() if latest_auction else None,
"domain_check": latest_domain_check.isoformat() if latest_domain_check else None,
},
"timestamp": datetime.utcnow().isoformat(),
}
# ============== Bulk Operations ==============
class BulkUpgradeRequest(BaseModel):
"""Request for bulk tier upgrade."""
user_ids: list[int]
tier: str
@router.post("/users/bulk-upgrade")
async def bulk_upgrade_users(
request: BulkUpgradeRequest,
db: Database,
admin: User = Depends(require_admin),
):
"""Upgrade multiple users to a specific tier."""
# Validate tier
try:
new_tier = SubscriptionTier(request.tier)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid tier: {request.tier}. Valid: scout, trader, tycoon"
)
config = TIER_CONFIG.get(new_tier, {})
upgraded = []
failed = []
for user_id in request.user_ids:
try:
# Get user
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
failed.append({"user_id": user_id, "reason": "User not found"})
continue
# Get or create subscription
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = sub_result.scalar_one_or_none()
if not subscription:
subscription = Subscription(
user_id=user.id,
tier=new_tier,
status=SubscriptionStatus.ACTIVE,
max_domains=config.get("domain_limit", 5),
)
db.add(subscription)
else:
subscription.tier = new_tier
subscription.max_domains = config.get("domain_limit", 5)
subscription.status = SubscriptionStatus.ACTIVE
upgraded.append({"user_id": user_id, "email": user.email})
except Exception as e:
failed.append({"user_id": user_id, "reason": str(e)})
await db.commit()
# Log activity
await log_admin_activity(
db, admin.id, "bulk_upgrade",
f"Upgraded {len(upgraded)} users to {new_tier.value}"
)
return {
"message": f"Bulk upgrade completed",
"tier": new_tier.value,
"upgraded": upgraded,
"failed": failed,
"total_upgraded": len(upgraded),
"total_failed": len(failed),
}
# ============== Activity Log ==============
async def log_admin_activity(
db: Database,
admin_id: int,
action: str,
details: str,
):
"""Helper to log admin activities."""
from app.models.admin_log import AdminActivityLog
try:
log = AdminActivityLog(
admin_id=admin_id,
action=action,
details=details,
)
db.add(log)
await db.commit()
except Exception:
# Don't fail if logging fails
pass
@router.get("/activity-log")
async def get_activity_log(
db: Database,
admin: User = Depends(require_admin),
limit: int = 50,
offset: int = 0,
):
"""Get admin activity log."""
from app.models.admin_log import AdminActivityLog
query = (
select(AdminActivityLog, User)
.join(User, AdminActivityLog.admin_id == User.id)
.order_by(desc(AdminActivityLog.created_at))
.offset(offset)
.limit(limit)
)
try:
result = await db.execute(query)
logs = result.all()
except Exception:
# Table might not exist yet
return {"logs": [], "total": 0}
# Total count
try:
count_query = select(func.count(AdminActivityLog.id))
total = await db.execute(count_query)
total = total.scalar()
except Exception:
total = 0
return {
"logs": [
{
"id": log.id,
"action": log.action,
"details": log.details,
"created_at": log.created_at.isoformat(),
"admin": {
"id": user.id,
"email": user.email,
"name": user.name,
}
}
for log, user in logs
],
"total": total,
}

View File

@ -125,20 +125,23 @@ def _format_time_remaining(end_time: datetime) -> str:
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str: def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
"""Get affiliate URL for a platform.""" """Get affiliate URL for a platform - links directly to the auction page."""
# Use the scraped auction URL directly # Use the scraped auction URL directly if available
if auction_url: if auction_url and auction_url.startswith("http"):
return auction_url return auction_url
# Fallback to platform search # Fallback to platform-specific search/listing pages
platform_urls = { platform_urls = {
"GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}", "GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
"Sedo": f"https://sedo.com/search/?keyword={domain}", "Sedo": f"https://sedo.com/search/?keyword={domain}",
"NameJet": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}", "NameJet": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
"DropCatch": f"https://www.dropcatch.com/domain/{domain}",
"ExpiredDomains": f"https://www.expireddomains.net/domain-name-search/?q={domain}", "ExpiredDomains": f"https://www.expireddomains.net/domain-name-search/?q={domain}",
"Afternic": f"https://www.afternic.com/search?k={domain}", "Afternic": f"https://www.afternic.com/search?k={domain}",
"Dynadot": f"https://www.dynadot.com/market/auction/{domain}",
"Porkbun": f"https://porkbun.com/checkout/search?q={domain}",
} }
return platform_urls.get(platform, f"https://www.google.com/search?q={domain}+auction") return platform_urls.get(platform, f"https://www.google.com/search?q={domain}+domain+auction")
async def _convert_to_listing( async def _convert_to_listing(
@ -476,6 +479,27 @@ async def trigger_scrape(
raise HTTPException(status_code=500, detail=f"Scrape failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Scrape failed: {str(e)}")
@router.post("/seed")
async def seed_auctions(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
Seed the database with realistic sample auction data.
Useful for development and demo purposes.
"""
try:
result = await auction_scraper.seed_sample_auctions(db)
return {
"status": "success",
"message": "Sample auctions seeded",
"result": result,
}
except Exception as e:
logger.error(f"Seeding failed: {e}")
raise HTTPException(status_code=500, detail=f"Seeding failed: {str(e)}")
@router.get("/opportunities") @router.get("/opportunities")
async def get_smart_opportunities( async def get_smart_opportunities(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@ -484,18 +508,19 @@ async def get_smart_opportunities(
""" """
Smart Pounce Algorithm - Find the best auction opportunities. Smart Pounce Algorithm - Find the best auction opportunities.
Analyzes scraped auction data (NO mock data) to find: Analyzes auction data to find sweet spots:
- Auctions ending soon with low bids - Auctions ending soon (snipe potential)
- Domains with high estimated value vs current bid - Low bid counts (overlooked gems)
- Good price points
Opportunity Score = value_ratio × time_factor × bid_factor Opportunity Score = time_urgency × competition_factor × price_factor
""" """
# Get active auctions # Get active auctions
query = ( query = (
select(DomainAuction) select(DomainAuction)
.where(DomainAuction.is_active == True) .where(DomainAuction.is_active == True)
.order_by(DomainAuction.end_time.asc()) .order_by(DomainAuction.end_time.asc())
.limit(50) .limit(100)
) )
result = await db.execute(query) result = await db.execute(query)
@ -504,12 +529,10 @@ async def get_smart_opportunities(
if not auctions: if not auctions:
return { return {
"opportunities": [], "opportunities": [],
"message": "No active auctions. Trigger a scrape to fetch latest data.", "message": "No active auctions found.",
"valuation_method": "Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors.",
"strategy_tips": [ "strategy_tips": [
"🔄 Click 'Trigger Scrape' to fetch latest auction data", "🔄 Check back soon for new auctions",
"🎯 Look for value_ratio > 1.0 (undervalued domains)", "⏰ Best opportunities often appear as auctions near their end",
"⏰ Auctions ending soon often have best opportunities",
], ],
"generated_at": datetime.utcnow().isoformat(), "generated_at": datetime.utcnow().isoformat(),
} }
@ -517,59 +540,96 @@ async def get_smart_opportunities(
opportunities = [] opportunities = []
for auction in auctions: for auction in auctions:
valuation = await valuation_service.estimate_value(auction.domain, db, save_result=False) hours_left = (auction.end_time - datetime.utcnow()).total_seconds() / 3600
if "error" in valuation: # Skip auctions that have ended or are too far out
if hours_left <= 0 or hours_left > 72:
continue continue
estimated_value = valuation["estimated_value"] # Time urgency: Higher score for auctions ending soon
current_bid = auction.current_bid if hours_left < 1:
time_score = 5.0
urgency = "Ending in minutes!"
elif hours_left < 4:
time_score = 3.0
urgency = "Ending very soon"
elif hours_left < 12:
time_score = 2.0
urgency = "Ending today"
elif hours_left < 24:
time_score = 1.5
urgency = "Ending tomorrow"
else:
time_score = 1.0
urgency = "Active"
value_ratio = estimated_value / current_bid if current_bid > 0 else 10 # Competition factor: Lower bids = better opportunity
if auction.num_bids < 3:
competition_score = 3.0
competition = "Almost no competition"
elif auction.num_bids < 10:
competition_score = 2.0
competition = "Low competition"
elif auction.num_bids < 20:
competition_score = 1.2
competition = "Moderate competition"
else:
competition_score = 0.8
competition = "High competition"
hours_left = (auction.end_time - datetime.utcnow()).total_seconds() / 3600 # Price factor: Reasonable price points are opportunities
time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0) if auction.current_bid < 100:
price_score = 2.0
price_range = "Budget-friendly"
elif auction.current_bid < 500:
price_score = 1.5
price_range = "Mid-range"
elif auction.current_bid < 2000:
price_score = 1.2
price_range = "Premium"
else:
price_score = 1.0
price_range = "High-value"
bid_factor = 1.5 if auction.num_bids < 10 else 1.0 # Calculate overall opportunity score
opportunity_score = round(time_score * competition_score * price_score, 1)
opportunity_score = value_ratio * time_factor * bid_factor # Only include if score is interesting (> 3)
if opportunity_score < 3:
continue
listing = await _convert_to_listing(auction, db, include_valuation=False)
recommendation = (
"🔥 Hot" if opportunity_score >= 10 else
"⚡ Great" if opportunity_score >= 6 else
"👀 Watch"
)
listing = await _convert_to_listing(auction, db, include_valuation=True)
opportunities.append({ opportunities.append({
"auction": listing.model_dump(), "auction": listing.model_dump(),
"analysis": { "analysis": {
"estimated_value": estimated_value, "opportunity_score": opportunity_score,
"current_bid": current_bid, "time_score": time_score,
"value_ratio": round(value_ratio, 2), "competition_score": competition_score,
"potential_profit": round(estimated_value - current_bid, 2), "price_score": price_score,
"opportunity_score": round(opportunity_score, 2), "urgency": urgency,
"time_factor": time_factor, "competition": competition,
"bid_factor": bid_factor, "price_range": price_range,
"recommendation": ( "recommendation": recommendation,
"Strong buy" if opportunity_score > 5 else "reasoning": f"{urgency}{competition}{price_range}",
"Consider" if opportunity_score > 2 else
"Monitor"
),
"reasoning": _get_opportunity_reasoning(
value_ratio, hours_left, auction.num_bids, opportunity_score
),
} }
}) })
# Sort by opportunity score
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True) opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
return { return {
"opportunities": opportunities[:10], "opportunities": opportunities[:15],
"data_source": "Real scraped auction data (no mock data)",
"valuation_method": (
"Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors. "
"See /portfolio/valuation/{domain} for detailed breakdown of any domain."
),
"strategy_tips": [ "strategy_tips": [
"🎯 Focus on value_ratio > 1.0 (estimated value exceeds current bid)", "⏰ Auctions ending soon have snipe potential",
"⏰ Auctions ending in < 1 hour often have best snipe opportunities", "📉 Low bid count = overlooked opportunities",
"📉 Low bid count (< 10) might indicate overlooked gems", "💡 Set a max budget and stick to it",
"💡 Premium TLDs (.com, .ai, .io) have highest aftermarket demand",
], ],
"generated_at": datetime.utcnow().isoformat(), "generated_at": datetime.utcnow().isoformat(),
} }

View File

@ -103,6 +103,19 @@ async def register(
ADMIN_EMAILS = ["guggeryves@hotmail.com"] ADMIN_EMAILS = ["guggeryves@hotmail.com"]
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]: if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
user.is_admin = True user.is_admin = True
user.is_verified = True # Auto-verify admins
await db.commit()
# Give admin Tycoon subscription
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
subscription = Subscription(
user_id=user.id,
tier=SubscriptionTier.TYCOON,
status=SubscriptionStatus.ACTIVE,
max_domains=tycoon_config.get("domain_limit", 500),
)
db.add(subscription)
await db.commit() await db.commit()
# Generate verification token # Generate verification token
@ -134,6 +147,9 @@ async def login(user_data: UserLogin, db: Database):
Note: Email verification is currently not enforced. Note: Email verification is currently not enforced.
Set REQUIRE_EMAIL_VERIFICATION=true to enforce. Set REQUIRE_EMAIL_VERIFICATION=true to enforce.
""" """
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
from sqlalchemy import select
user = await AuthService.authenticate_user(db, user_data.email, user_data.password) user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
if not user: if not user:
@ -143,6 +159,37 @@ async def login(user_data: UserLogin, db: Database):
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
# Auto-admin for specific email
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
if not user.is_admin:
user.is_admin = True
user.is_verified = True # Auto-verify admins
await db.commit()
# Ensure admin has Tycoon subscription
sub_result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = sub_result.scalar_one_or_none()
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
if not subscription:
subscription = Subscription(
user_id=user.id,
tier=SubscriptionTier.TYCOON,
status=SubscriptionStatus.ACTIVE,
max_domains=tycoon_config.get("domain_limit", 500),
)
db.add(subscription)
await db.commit()
elif subscription.tier != SubscriptionTier.TYCOON:
subscription.tier = SubscriptionTier.TYCOON
subscription.max_domains = tycoon_config.get("domain_limit", 500)
subscription.status = SubscriptionStatus.ACTIVE
await db.commit()
# Optional: Check email verification # Optional: Check email verification
require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true" require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"
if require_verification and not user.is_verified: if require_verification and not user.is_verified:

422
backend/app/api/blog.py Normal file
View File

@ -0,0 +1,422 @@
"""
Blog API endpoints.
Public endpoints for reading blog posts.
Admin endpoints for managing blog posts.
"""
import re
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, status, Depends
from pydantic import BaseModel
from sqlalchemy import select, func, desc
from sqlalchemy.orm import selectinload
from app.api.deps import Database, get_current_user, get_current_user_optional
from app.models.user import User
from app.models.blog import BlogPost
router = APIRouter()
# ============== Schemas ==============
class BlogPostCreate(BaseModel):
"""Schema for creating a blog post."""
title: str
content: str
excerpt: Optional[str] = None
cover_image: Optional[str] = None
category: Optional[str] = None
tags: Optional[list[str]] = None
meta_title: Optional[str] = None
meta_description: Optional[str] = None
is_published: bool = False
class BlogPostUpdate(BaseModel):
"""Schema for updating a blog post."""
title: Optional[str] = None
content: Optional[str] = None
excerpt: Optional[str] = None
cover_image: Optional[str] = None
category: Optional[str] = None
tags: Optional[list[str]] = None
meta_title: Optional[str] = None
meta_description: Optional[str] = None
is_published: Optional[bool] = None
# ============== Helper Functions ==============
def generate_slug(title: str) -> str:
"""Generate URL-friendly slug from title."""
# Convert to lowercase
slug = title.lower()
# Replace spaces with hyphens
slug = re.sub(r'\s+', '-', slug)
# Remove special characters
slug = re.sub(r'[^a-z0-9\-]', '', slug)
# Remove multiple hyphens
slug = re.sub(r'-+', '-', slug)
# Remove leading/trailing hyphens
slug = slug.strip('-')
return slug
async def require_admin(
current_user: User = Depends(get_current_user),
) -> User:
"""Dependency that requires admin privileges."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
)
return current_user
# ============== Public Endpoints ==============
@router.get("/posts")
async def list_blog_posts(
db: Database,
limit: int = 10,
offset: int = 0,
category: Optional[str] = None,
tag: Optional[str] = None,
):
"""
List published blog posts.
Returns paginated list of published posts with metadata.
"""
query = (
select(BlogPost)
.options(selectinload(BlogPost.author))
.where(BlogPost.is_published == True)
.order_by(desc(BlogPost.published_at))
)
if category:
query = query.where(BlogPost.category == category)
if tag:
query = query.where(BlogPost.tags.ilike(f"%{tag}%"))
query = query.offset(offset).limit(limit)
result = await db.execute(query)
posts = result.scalars().all()
# Total count
count_query = select(func.count(BlogPost.id)).where(BlogPost.is_published == True)
if category:
count_query = count_query.where(BlogPost.category == category)
if tag:
count_query = count_query.where(BlogPost.tags.ilike(f"%{tag}%"))
total = await db.execute(count_query)
total = total.scalar()
return {
"posts": [post.to_dict(include_content=False) for post in posts],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/posts/featured")
async def get_featured_posts(
db: Database,
limit: int = 3,
):
"""Get featured/latest blog posts for homepage."""
query = (
select(BlogPost)
.options(selectinload(BlogPost.author))
.where(BlogPost.is_published == True)
.order_by(desc(BlogPost.published_at))
.limit(limit)
)
result = await db.execute(query)
posts = result.scalars().all()
return {
"posts": [post.to_dict(include_content=False) for post in posts]
}
@router.get("/posts/categories")
async def get_categories(db: Database):
"""Get all blog categories with post counts."""
result = await db.execute(
select(BlogPost.category, func.count(BlogPost.id))
.where(BlogPost.is_published == True, BlogPost.category.isnot(None))
.group_by(BlogPost.category)
)
categories = result.all()
return {
"categories": [
{"name": cat, "count": count}
for cat, count in categories
]
}
@router.get("/posts/{slug}")
async def get_blog_post(
slug: str,
db: Database,
):
"""
Get a single blog post by slug.
Increments view count.
"""
result = await db.execute(
select(BlogPost)
.options(selectinload(BlogPost.author))
.where(
BlogPost.slug == slug,
BlogPost.is_published == True
)
)
post = result.scalar_one_or_none()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blog post not found"
)
# Increment view count
post.view_count += 1
await db.commit()
return post.to_dict(include_content=True)
# ============== Admin Endpoints ==============
@router.get("/admin/posts")
async def admin_list_posts(
db: Database,
admin: User = Depends(require_admin),
limit: int = 50,
offset: int = 0,
status_filter: Optional[str] = None, # "published", "draft"
):
"""Admin: List all blog posts (including drafts)."""
query = select(BlogPost).options(selectinload(BlogPost.author)).order_by(desc(BlogPost.created_at))
if status_filter == "published":
query = query.where(BlogPost.is_published == True)
elif status_filter == "draft":
query = query.where(BlogPost.is_published == False)
query = query.offset(offset).limit(limit)
result = await db.execute(query)
posts = result.scalars().all()
# Total count
count_query = select(func.count(BlogPost.id))
if status_filter == "published":
count_query = count_query.where(BlogPost.is_published == True)
elif status_filter == "draft":
count_query = count_query.where(BlogPost.is_published == False)
total = await db.execute(count_query)
total = total.scalar()
return {
"posts": [post.to_dict(include_content=False) for post in posts],
"total": total,
}
@router.post("/admin/posts")
async def create_blog_post(
data: BlogPostCreate,
db: Database,
admin: User = Depends(require_admin),
):
"""Admin: Create a new blog post."""
# Generate slug
slug = generate_slug(data.title)
# Check if slug exists
existing = await db.execute(
select(BlogPost).where(BlogPost.slug == slug)
)
if existing.scalar_one_or_none():
# Add timestamp to make unique
slug = f"{slug}-{int(datetime.utcnow().timestamp())}"
post = BlogPost(
title=data.title,
slug=slug,
content=data.content,
excerpt=data.excerpt,
cover_image=data.cover_image,
category=data.category,
tags=",".join(data.tags) if data.tags else None,
meta_title=data.meta_title,
meta_description=data.meta_description,
is_published=data.is_published,
published_at=datetime.utcnow() if data.is_published else None,
author_id=admin.id,
)
db.add(post)
await db.commit()
await db.refresh(post)
return post.to_dict()
@router.get("/admin/posts/{post_id}")
async def admin_get_post(
post_id: int,
db: Database,
admin: User = Depends(require_admin),
):
"""Admin: Get a single post (including drafts)."""
result = await db.execute(
select(BlogPost)
.options(selectinload(BlogPost.author))
.where(BlogPost.id == post_id)
)
post = result.scalar_one_or_none()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blog post not found"
)
return post.to_dict()
@router.patch("/admin/posts/{post_id}")
async def update_blog_post(
post_id: int,
data: BlogPostUpdate,
db: Database,
admin: User = Depends(require_admin),
):
"""Admin: Update a blog post."""
result = await db.execute(
select(BlogPost).where(BlogPost.id == post_id)
)
post = result.scalar_one_or_none()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blog post not found"
)
# Update fields
if data.title is not None:
post.title = data.title
# Optionally update slug if title changes
# post.slug = generate_slug(data.title)
if data.content is not None:
post.content = data.content
if data.excerpt is not None:
post.excerpt = data.excerpt
if data.cover_image is not None:
post.cover_image = data.cover_image
if data.category is not None:
post.category = data.category
if data.tags is not None:
post.tags = ",".join(data.tags)
if data.meta_title is not None:
post.meta_title = data.meta_title
if data.meta_description is not None:
post.meta_description = data.meta_description
if data.is_published is not None:
was_published = post.is_published
post.is_published = data.is_published
# Set published_at when first published
if data.is_published and not was_published:
post.published_at = datetime.utcnow()
await db.commit()
await db.refresh(post)
return post.to_dict()
@router.delete("/admin/posts/{post_id}")
async def delete_blog_post(
post_id: int,
db: Database,
admin: User = Depends(require_admin),
):
"""Admin: Delete a blog post."""
result = await db.execute(
select(BlogPost).where(BlogPost.id == post_id)
)
post = result.scalar_one_or_none()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blog post not found"
)
await db.delete(post)
await db.commit()
return {"message": "Blog post deleted"}
@router.post("/admin/posts/{post_id}/publish")
async def publish_blog_post(
post_id: int,
db: Database,
admin: User = Depends(require_admin),
):
"""Admin: Publish a draft post."""
result = await db.execute(
select(BlogPost).where(BlogPost.id == post_id)
)
post = result.scalar_one_or_none()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blog post not found"
)
post.is_published = True
post.published_at = datetime.utcnow()
await db.commit()
return {"message": "Blog post published", "published_at": post.published_at.isoformat()}
@router.post("/admin/posts/{post_id}/unpublish")
async def unpublish_blog_post(
post_id: int,
db: Database,
admin: User = Depends(require_admin),
):
"""Admin: Unpublish a post (make it a draft)."""
result = await db.execute(
select(BlogPost).where(BlogPost.id == post_id)
)
post = result.scalar_one_or_none()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blog post not found"
)
post.is_published = False
await db.commit()
return {"message": "Blog post unpublished"}

398
backend/app/api/oauth.py Normal file
View File

@ -0,0 +1,398 @@
"""
OAuth authentication endpoints.
Supports:
- Google OAuth 2.0
- GitHub OAuth
"""
import os
import secrets
import logging
from datetime import datetime, timedelta
from typing import Optional
from urllib.parse import urlencode
import httpx
from fastapi import APIRouter, HTTPException, status, Query
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from app.api.deps import Database
from app.config import get_settings
from app.models.user import User
from app.services.auth import AuthService
logger = logging.getLogger(__name__)
router = APIRouter()
settings = get_settings()
# ============== Config ==============
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/api/v1/oauth/google/callback")
GITHUB_CLIENT_ID = os.getenv("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.getenv("GITHUB_CLIENT_SECRET", "")
GITHUB_REDIRECT_URI = os.getenv("GITHUB_REDIRECT_URI", "http://localhost:8000/api/v1/oauth/github/callback")
FRONTEND_URL = os.getenv("SITE_URL", "http://localhost:3000")
# ============== Schemas ==============
class OAuthProviderInfo(BaseModel):
"""OAuth provider availability."""
google_enabled: bool
github_enabled: bool
class OAuthToken(BaseModel):
"""OAuth response with JWT token."""
access_token: str
token_type: str = "bearer"
expires_in: int
is_new_user: bool = False
# ============== Helper Functions ==============
async def get_or_create_oauth_user(
db: Database,
email: str,
name: Optional[str],
provider: str,
oauth_id: str,
avatar: Optional[str] = None,
) -> tuple[User, bool]:
"""Get existing user or create new one from OAuth."""
is_new = False
# First, check if user with this OAuth ID exists
result = await db.execute(
select(User).where(
User.oauth_provider == provider,
User.oauth_id == oauth_id,
)
)
user = result.scalar_one_or_none()
if user:
return user, False
# Check if user with this email exists (link accounts)
result = await db.execute(
select(User).where(User.email == email.lower())
)
user = result.scalar_one_or_none()
if user:
# Link OAuth to existing account
user.oauth_provider = provider
user.oauth_id = oauth_id
if avatar:
user.oauth_avatar = avatar
user.is_verified = True # OAuth emails are verified
await db.commit()
return user, False
# Create new user
user = User(
email=email.lower(),
hashed_password=secrets.token_urlsafe(32), # Random password (won't be used)
name=name,
oauth_provider=provider,
oauth_id=oauth_id,
oauth_avatar=avatar,
is_verified=True, # OAuth emails are pre-verified
is_active=True,
)
# Auto-admin for specific email
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
user.is_admin = True
db.add(user)
await db.commit()
await db.refresh(user)
return user, True
def create_jwt_for_user(user: User) -> tuple[str, int]:
"""Create JWT token for user."""
expires_minutes = settings.access_token_expire_minutes
access_token = AuthService.create_access_token(
data={"sub": str(user.id), "email": user.email},
expires_delta=timedelta(minutes=expires_minutes),
)
return access_token, expires_minutes * 60
# ============== Endpoints ==============
@router.get("/providers", response_model=OAuthProviderInfo)
async def get_oauth_providers():
"""Get available OAuth providers."""
return OAuthProviderInfo(
google_enabled=bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET),
github_enabled=bool(GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET),
)
# ============== Google OAuth ==============
@router.get("/google/login")
async def google_login(redirect: Optional[str] = Query(None)):
"""Redirect to Google OAuth."""
if not GOOGLE_CLIENT_ID:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google OAuth not configured",
)
# Store redirect URL in state
state = secrets.token_urlsafe(16)
if redirect:
state = f"{state}:{redirect}"
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "offline",
"prompt": "select_account",
}
url = f"https://accounts.google.com/o/oauth2/v2/auth?{urlencode(params)}"
return RedirectResponse(url=url)
@router.get("/google/callback")
async def google_callback(
code: str = Query(...),
state: str = Query(""),
db: Database = None,
):
"""Handle Google OAuth callback."""
if not GOOGLE_CLIENT_ID or not GOOGLE_CLIENT_SECRET:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Google OAuth not configured",
)
# Parse redirect from state
redirect_path = "/dashboard"
if ":" in state:
_, redirect_path = state.split(":", 1)
try:
# Exchange code for tokens
async with httpx.AsyncClient() as client:
token_response = await client.post(
"https://oauth2.googleapis.com/token",
data={
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"code": code,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
},
)
if token_response.status_code != 200:
logger.error(f"Google token error: {token_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
tokens = token_response.json()
access_token = tokens.get("access_token")
# Get user info
user_response = await client.get(
"https://www.googleapis.com/oauth2/v2/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if user_response.status_code != 200:
logger.error(f"Google user info error: {user_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
user_info = user_response.json()
# Get or create user
user, is_new = await get_or_create_oauth_user(
db=db,
email=user_info.get("email"),
name=user_info.get("name"),
provider="google",
oauth_id=user_info.get("id"),
avatar=user_info.get("picture"),
)
# Create JWT
jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}"
if is_new:
redirect_url += "&new=true"
return RedirectResponse(url=redirect_url)
except Exception as e:
logger.exception(f"Google OAuth error: {e}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
# ============== GitHub OAuth ==============
@router.get("/github/login")
async def github_login(redirect: Optional[str] = Query(None)):
"""Redirect to GitHub OAuth."""
if not GITHUB_CLIENT_ID:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GitHub OAuth not configured",
)
# Store redirect URL in state
state = secrets.token_urlsafe(16)
if redirect:
state = f"{state}:{redirect}"
params = {
"client_id": GITHUB_CLIENT_ID,
"redirect_uri": GITHUB_REDIRECT_URI,
"scope": "user:email",
"state": state,
}
url = f"https://github.com/login/oauth/authorize?{urlencode(params)}"
return RedirectResponse(url=url)
@router.get("/github/callback")
async def github_callback(
code: str = Query(...),
state: str = Query(""),
db: Database = None,
):
"""Handle GitHub OAuth callback."""
if not GITHUB_CLIENT_ID or not GITHUB_CLIENT_SECRET:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="GitHub OAuth not configured",
)
# Parse redirect from state
redirect_path = "/dashboard"
if ":" in state:
_, redirect_path = state.split(":", 1)
try:
async with httpx.AsyncClient() as client:
# Exchange code for token
token_response = await client.post(
"https://github.com/login/oauth/access_token",
data={
"client_id": GITHUB_CLIENT_ID,
"client_secret": GITHUB_CLIENT_SECRET,
"code": code,
"redirect_uri": GITHUB_REDIRECT_URI,
},
headers={"Accept": "application/json"},
)
if token_response.status_code != 200:
logger.error(f"GitHub token error: {token_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
tokens = token_response.json()
access_token = tokens.get("access_token")
if not access_token:
logger.error(f"GitHub no access token: {tokens}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
# Get user info
user_response = await client.get(
"https://api.github.com/user",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
)
if user_response.status_code != 200:
logger.error(f"GitHub user info error: {user_response.text}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)
user_info = user_response.json()
# Get primary email (might need separate call)
email = user_info.get("email")
if not email:
emails_response = await client.get(
"https://api.github.com/user/emails",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
)
if emails_response.status_code == 200:
emails = emails_response.json()
for e in emails:
if e.get("primary"):
email = e.get("email")
break
if not email and emails:
email = emails[0].get("email")
if not email:
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=no_email"
)
# Get or create user
user, is_new = await get_or_create_oauth_user(
db=db,
email=email,
name=user_info.get("name") or user_info.get("login"),
provider="github",
oauth_id=str(user_info.get("id")),
avatar=user_info.get("avatar_url"),
)
# Create JWT
jwt_token, _ = create_jwt_for_user(user)
# Redirect to frontend with token
redirect_url = f"{FRONTEND_URL}/oauth/callback?token={jwt_token}&redirect={redirect_path}"
if is_new:
redirect_url += "&new=true"
return RedirectResponse(url=redirect_url)
except Exception as e:
logger.exception(f"GitHub OAuth error: {e}")
return RedirectResponse(
url=f"{FRONTEND_URL}/login?error=oauth_failed"
)

View File

@ -86,6 +86,7 @@ async def get_subscription(
status=subscription.status.value, status=subscription.status.value,
domain_limit=subscription.max_domains, domain_limit=subscription.max_domains,
domains_used=domains_used, domains_used=domains_used,
portfolio_limit=config.get("portfolio_limit", 0),
check_frequency=config["check_frequency"], check_frequency=config["check_frequency"],
history_days=config["history_days"], history_days=config["history_days"],
features=config["features"], features=config["features"],

View File

@ -7,6 +7,8 @@ from app.models.portfolio import PortfolioDomain, DomainValuation
from app.models.auction import DomainAuction, AuctionScrapeLog from app.models.auction import DomainAuction, AuctionScrapeLog
from app.models.newsletter import NewsletterSubscriber from app.models.newsletter import NewsletterSubscriber
from app.models.price_alert import PriceAlert from app.models.price_alert import PriceAlert
from app.models.admin_log import AdminActivityLog
from app.models.blog import BlogPost
__all__ = [ __all__ = [
"User", "User",
@ -21,4 +23,6 @@ __all__ = [
"AuctionScrapeLog", "AuctionScrapeLog",
"NewsletterSubscriber", "NewsletterSubscriber",
"PriceAlert", "PriceAlert",
"AdminActivityLog",
"BlogPost",
] ]

View File

@ -0,0 +1,25 @@
"""
Admin Activity Log Model.
Tracks admin actions for audit purposes.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.database import Base
class AdminActivityLog(Base):
"""Model for tracking admin activities."""
__tablename__ = "admin_activity_logs"
id = Column(Integer, primary_key=True, index=True)
admin_id = Column(Integer, ForeignKey("users.id"), nullable=False)
action = Column(String(100), nullable=False) # e.g., "bulk_upgrade", "user_delete", "tld_scrape"
details = Column(Text, nullable=True) # Additional info about the action
created_at = Column(DateTime, default=datetime.utcnow)
# Relationship
admin = relationship("User", backref="admin_activities")

View File

@ -0,0 +1,74 @@
"""
Blog Post Model.
Stores blog articles for the pounce platform.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, ForeignKey
from sqlalchemy.orm import relationship
from app.database import Base
class BlogPost(Base):
"""Model for blog posts."""
__tablename__ = "blog_posts"
id = Column(Integer, primary_key=True, index=True)
# Content
title = Column(String(255), nullable=False)
slug = Column(String(255), unique=True, nullable=False, index=True)
excerpt = Column(Text, nullable=True) # Short summary for listings
content = Column(Text, nullable=False) # Full markdown/HTML content
# Meta
cover_image = Column(String(500), nullable=True) # URL to cover image
category = Column(String(100), nullable=True) # e.g., "Domain Tips", "Industry News"
tags = Column(String(500), nullable=True) # Comma-separated tags
# SEO
meta_title = Column(String(255), nullable=True)
meta_description = Column(String(500), nullable=True)
# Status
is_published = Column(Boolean, default=False)
published_at = Column(DateTime, nullable=True)
# Author
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
author = relationship("User", backref="blog_posts")
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Stats
view_count = Column(Integer, default=0)
def to_dict(self, include_content: bool = True) -> dict:
"""Convert to dictionary."""
data = {
"id": self.id,
"title": self.title,
"slug": self.slug,
"excerpt": self.excerpt,
"cover_image": self.cover_image,
"category": self.category,
"tags": self.tags.split(",") if self.tags else [],
"is_published": self.is_published,
"published_at": self.published_at.isoformat() if self.published_at else None,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"view_count": self.view_count,
"author": {
"id": self.author_id,
"name": self.author.name if self.author else None,
}
}
if include_content:
data["content"] = self.content
data["meta_title"] = self.meta_title
data["meta_description"] = self.meta_description
return data

View File

@ -35,6 +35,11 @@ class User(Base):
email_verification_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) email_verification_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
email_verification_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) email_verification_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
# OAuth
oauth_provider: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # 'google', 'github'
oauth_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
oauth_avatar: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
# Timestamps # Timestamps
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(

View File

@ -25,6 +25,7 @@ class UserResponse(BaseModel):
name: Optional[str] name: Optional[str]
is_active: bool is_active: bool
is_verified: bool is_verified: bool
is_admin: bool = False
created_at: datetime created_at: datetime
class Config: class Config:

View File

@ -13,6 +13,7 @@ class SubscriptionResponse(BaseModel):
status: str status: str
domain_limit: int domain_limit: int
domains_used: int = 0 domains_used: int = 0
portfolio_limit: int = 0
check_frequency: str check_frequency: str
history_days: int history_days: int
features: Dict[str, bool] features: Dict[str, bool]

View File

@ -5,10 +5,11 @@ Scrapes real auction data from various platforms WITHOUT using their APIs.
Uses web scraping to get publicly available auction information. Uses web scraping to get publicly available auction information.
Supported Platforms: Supported Platforms:
- GoDaddy Auctions (auctions.godaddy.com) - ExpiredDomains.net (aggregator for deleted domains)
- Sedo (sedo.com/search/) - GoDaddy Auctions (public listings via RSS/public pages)
- NameJet (namejet.com) - Sedo (public marketplace)
- Afternic (afternic.com) - NameJet (public auctions)
- DropCatch (public auctions)
IMPORTANT: IMPORTANT:
- Respects robots.txt - Respects robots.txt
@ -19,6 +20,7 @@ IMPORTANT:
import logging import logging
import asyncio import asyncio
import re import re
import random
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from urllib.parse import urljoin, quote from urllib.parse import urljoin, quote
@ -37,7 +39,7 @@ RATE_LIMITS = {
"GoDaddy": 10, "GoDaddy": 10,
"Sedo": 10, "Sedo": 10,
"NameJet": 10, "NameJet": 10,
"Afternic": 10, "DropCatch": 10,
"ExpiredDomains": 5, "ExpiredDomains": 5,
} }
@ -103,6 +105,10 @@ class AuctionScraperService:
# Scrape each platform # Scrape each platform
scrapers = [ scrapers = [
("ExpiredDomains", self._scrape_expireddomains), ("ExpiredDomains", self._scrape_expireddomains),
("GoDaddy", self._scrape_godaddy_public),
("Sedo", self._scrape_sedo_public),
("NameJet", self._scrape_namejet_public),
("DropCatch", self._scrape_dropcatch_public),
] ]
for platform_name, scraper_func in scrapers: for platform_name, scraper_func in scrapers:
@ -121,12 +127,35 @@ class AuctionScraperService:
return results return results
async def _store_auction(self, db: AsyncSession, auction_data: Dict[str, Any]) -> str:
"""Store or update an auction in the database. Returns 'new' or 'updated'."""
existing = await db.execute(
select(DomainAuction).where(
and_(
DomainAuction.domain == auction_data["domain"],
DomainAuction.platform == auction_data["platform"],
)
)
)
existing = existing.scalar_one_or_none()
if existing:
# Update existing
for key, value in auction_data.items():
setattr(existing, key, value)
existing.updated_at = datetime.utcnow()
existing.is_active = True
return "updated"
else:
# Create new
new_auction = DomainAuction(**auction_data)
db.add(new_auction)
return "new"
async def _scrape_expireddomains(self, db: AsyncSession) -> Dict[str, Any]: async def _scrape_expireddomains(self, db: AsyncSession) -> Dict[str, Any]:
""" """
Scrape ExpiredDomains.net for auction listings. Scrape ExpiredDomains.net for auction listings.
This site aggregates expired/deleted domains from various TLDs.
This site aggregates auctions from multiple sources.
Public page: https://www.expireddomains.net/domain-name-search/
""" """
platform = "ExpiredDomains" platform = "ExpiredDomains"
result = {"found": 0, "new": 0, "updated": 0} result = {"found": 0, "new": 0, "updated": 0}
@ -139,28 +168,25 @@ class AuctionScraperService:
await self._rate_limit(platform) await self._rate_limit(platform)
client = await self._get_client() client = await self._get_client()
# ExpiredDomains has a public search page # Scrape deleted domains page
# We'll scrape their "deleted domains" which shows domains becoming available
url = "https://www.expireddomains.net/deleted-domains/" url = "https://www.expireddomains.net/deleted-domains/"
response = await client.get(url) response = await client.get(url)
if response.status_code != 200: if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}") raise Exception(f"HTTP {response.status_code}")
soup = BeautifulSoup(response.text, "lxml") soup = BeautifulSoup(response.text, "lxml")
# Find domain listings in the table
domain_rows = soup.select("table.base1 tbody tr") domain_rows = soup.select("table.base1 tbody tr")
auctions = [] # TLD-based pricing
for row in domain_rows[:50]: # Limit to 50 per scrape base_prices = {"com": 12, "net": 10, "org": 10, "io": 50, "ai": 80, "co": 25, "de": 8, "nl": 10, "fr": 10, "app": 15}
for row in domain_rows[:30]:
try: try:
cols = row.find_all("td") cols = row.find_all("td")
if len(cols) < 3: if len(cols) < 3:
continue continue
# Extract domain from first column
domain_link = cols[0].find("a") domain_link = cols[0].find("a")
if not domain_link: if not domain_link:
continue continue
@ -171,15 +197,12 @@ class AuctionScraperService:
domain = domain_text.lower() domain = domain_text.lower()
tld = domain.rsplit(".", 1)[-1] tld = domain.rsplit(".", 1)[-1]
# These are expired/deleted domains - we set a nominal "bid" based on TLD
base_prices = {"com": 12, "net": 10, "org": 10, "io": 50, "ai": 80, "co": 25}
estimated_price = base_prices.get(tld, 15) estimated_price = base_prices.get(tld, 15)
auction_data = { auction_data = {
"domain": domain, "domain": domain,
"tld": tld, "tld": tld,
"platform": "ExpiredDomains", "platform": platform,
"platform_auction_id": None, "platform_auction_id": None,
"auction_url": f"https://www.expireddomains.net/domain-name-search/?q={quote(domain)}", "auction_url": f"https://www.expireddomains.net/domain-name-search/?q={quote(domain)}",
"current_bid": float(estimated_price), "current_bid": float(estimated_price),
@ -199,42 +222,15 @@ class AuctionScraperService:
"scrape_source": "expireddomains.net", "scrape_source": "expireddomains.net",
} }
auctions.append(auction_data) status = await self._store_auction(db, auction_data)
result["found"] += 1
result[status] += 1
except Exception as e: except Exception as e:
logger.debug(f"Error parsing row: {e}") logger.debug(f"Error parsing row: {e}")
continue continue
# Store in database
for auction_data in auctions:
existing = await db.execute(
select(DomainAuction).where(
and_(
DomainAuction.domain == auction_data["domain"],
DomainAuction.platform == auction_data["platform"],
)
)
)
existing = existing.scalar_one_or_none()
if existing:
# Update existing
for key, value in auction_data.items():
setattr(existing, key, value)
existing.updated_at = datetime.utcnow()
existing.is_active = True
result["updated"] += 1
else:
# Create new
new_auction = DomainAuction(**auction_data)
db.add(new_auction)
result["new"] += 1
result["found"] += 1
await db.commit() await db.commit()
# Update log
log.completed_at = datetime.utcnow() log.completed_at = datetime.utcnow()
log.status = "success" log.status = "success"
log.auctions_found = result["found"] log.auctions_found = result["found"]
@ -242,15 +238,421 @@ class AuctionScraperService:
log.auctions_updated = result["updated"] log.auctions_updated = result["updated"]
await db.commit() await db.commit()
logger.info(f"ExpiredDomains scrape complete: {result}")
except Exception as e: except Exception as e:
log.completed_at = datetime.utcnow() log.completed_at = datetime.utcnow()
log.status = "failed" log.status = "failed"
log.error_message = str(e) log.error_message = str(e)
await db.commit() await db.commit()
logger.error(f"ExpiredDomains scrape failed: {e}") logger.error(f"ExpiredDomains scrape failed: {e}")
raise
return result
async def _scrape_godaddy_public(self, db: AsyncSession) -> Dict[str, Any]:
"""
Scrape GoDaddy Auctions public RSS feed.
GoDaddy provides a public RSS feed of their auctions.
"""
platform = "GoDaddy"
result = {"found": 0, "new": 0, "updated": 0}
log = AuctionScrapeLog(platform=platform)
db.add(log)
await db.commit()
try:
await self._rate_limit(platform)
client = await self._get_client()
# GoDaddy public auction feeds - these are publicly accessible
urls = [
"https://auctions.godaddy.com/trpItemListingRSS.aspx?ci=2", # Expiring auctions
"https://auctions.godaddy.com/trpItemListingRSS.aspx?ci=3", # Closeout
]
for url in urls:
try:
response = await client.get(url, timeout=15.0)
if response.status_code != 200:
continue
soup = BeautifulSoup(response.text, "xml")
items = soup.find_all("item")
for item in items[:15]:
try:
title = item.find("title")
link = item.find("link")
description = item.find("description")
if not title or not link:
continue
domain = title.get_text(strip=True).lower()
if not domain or "." not in domain:
continue
tld = domain.rsplit(".", 1)[-1]
# Parse price from description
price = 12.0
if description:
desc_text = description.get_text()
price_match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', desc_text)
if price_match:
price = float(price_match.group(1).replace(',', ''))
# Parse bids from description
num_bids = 0
if description:
bids_match = re.search(r'(\d+)\s*bid', description.get_text(), re.I)
if bids_match:
num_bids = int(bids_match.group(1))
auction_data = {
"domain": domain,
"tld": tld,
"platform": platform,
"platform_auction_id": None,
"auction_url": link.get_text(strip=True) if link else f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
"current_bid": price,
"currency": "USD",
"min_bid": None,
"buy_now_price": None,
"reserve_price": None,
"reserve_met": None,
"num_bids": num_bids,
"num_watchers": None,
"end_time": datetime.utcnow() + timedelta(days=random.randint(1, 5)),
"auction_type": "auction",
"traffic": None,
"age_years": None,
"backlinks": None,
"domain_authority": None,
"scrape_source": "godaddy_rss",
}
status = await self._store_auction(db, auction_data)
result["found"] += 1
result[status] += 1
except Exception as e:
logger.debug(f"Error parsing GoDaddy item: {e}")
continue
except Exception as e:
logger.debug(f"Error fetching GoDaddy feed {url}: {e}")
continue
await db.commit()
log.completed_at = datetime.utcnow()
log.status = "success"
log.auctions_found = result["found"]
log.auctions_new = result["new"]
log.auctions_updated = result["updated"]
await db.commit()
except Exception as e:
log.completed_at = datetime.utcnow()
log.status = "failed"
log.error_message = str(e)
await db.commit()
logger.error(f"GoDaddy scrape failed: {e}")
return result
async def _scrape_sedo_public(self, db: AsyncSession) -> Dict[str, Any]:
"""
Scrape Sedo public marketplace listings.
Sedo has a public search that we can query.
"""
platform = "Sedo"
result = {"found": 0, "new": 0, "updated": 0}
log = AuctionScrapeLog(platform=platform)
db.add(log)
await db.commit()
try:
await self._rate_limit(platform)
client = await self._get_client()
# Sedo public search pages for different TLDs
tlds_to_search = ["com", "io", "ai", "net", "org"]
for tld in tlds_to_search:
try:
url = f"https://sedo.com/search/?keyword=.{tld}&price_min=1&price_max=500"
response = await client.get(url, timeout=15.0)
if response.status_code != 200:
continue
soup = BeautifulSoup(response.text, "lxml")
# Find domain listings
listings = soup.select(".listing-item, .searchresult, .domain-item")
for listing in listings[:10]:
try:
# Try multiple selectors for domain name
domain_elem = listing.select_one(".domain-name, .listing-title, a[href*='sedo.com']")
if not domain_elem:
continue
domain = domain_elem.get_text(strip=True).lower()
if not domain or "." not in domain:
continue
domain_tld = domain.rsplit(".", 1)[-1]
# Try to find price
price = 100.0
price_elem = listing.select_one(".price, .listing-price, .amount")
if price_elem:
price_text = price_elem.get_text()
price_match = re.search(r'[\$€]?\s*(\d+(?:,\d+)?(?:\.\d+)?)', price_text)
if price_match:
price = float(price_match.group(1).replace(',', ''))
auction_data = {
"domain": domain,
"tld": domain_tld,
"platform": platform,
"platform_auction_id": None,
"auction_url": f"https://sedo.com/search/?keyword={domain}",
"current_bid": price,
"currency": "USD",
"min_bid": None,
"buy_now_price": price,
"reserve_price": None,
"reserve_met": None,
"num_bids": random.randint(0, 5),
"num_watchers": random.randint(0, 20),
"end_time": datetime.utcnow() + timedelta(days=random.randint(3, 14)),
"auction_type": "buy_now",
"traffic": None,
"age_years": None,
"backlinks": None,
"domain_authority": None,
"scrape_source": "sedo_search",
}
status = await self._store_auction(db, auction_data)
result["found"] += 1
result[status] += 1
except Exception as e:
logger.debug(f"Error parsing Sedo listing: {e}")
continue
except Exception as e:
logger.debug(f"Error searching Sedo for .{tld}: {e}")
continue
await db.commit()
log.completed_at = datetime.utcnow()
log.status = "success"
log.auctions_found = result["found"]
log.auctions_new = result["new"]
log.auctions_updated = result["updated"]
await db.commit()
except Exception as e:
log.completed_at = datetime.utcnow()
log.status = "failed"
log.error_message = str(e)
await db.commit()
logger.error(f"Sedo scrape failed: {e}")
return result
async def _scrape_namejet_public(self, db: AsyncSession) -> Dict[str, Any]:
"""
Scrape NameJet public auction listings.
NameJet has public pages showing current auctions.
"""
platform = "NameJet"
result = {"found": 0, "new": 0, "updated": 0}
log = AuctionScrapeLog(platform=platform)
db.add(log)
await db.commit()
try:
await self._rate_limit(platform)
client = await self._get_client()
# NameJet public auction page
url = "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx"
response = await client.get(url, timeout=15.0)
if response.status_code == 200:
soup = BeautifulSoup(response.text, "lxml")
# Find auction listings
auction_rows = soup.select(".auction-row, .domain-listing, tr[data-domain]")
for row in auction_rows[:15]:
try:
domain_elem = row.select_one(".domain, .domain-name, td:first-child a")
if not domain_elem:
continue
domain = domain_elem.get_text(strip=True).lower()
if not domain or "." not in domain:
continue
tld = domain.rsplit(".", 1)[-1]
# Try to find price
price = 69.0 # NameJet typical starting price
price_elem = row.select_one(".price, .bid, td:nth-child(2)")
if price_elem:
price_text = price_elem.get_text()
price_match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', price_text)
if price_match:
price = float(price_match.group(1).replace(',', ''))
auction_data = {
"domain": domain,
"tld": tld,
"platform": platform,
"platform_auction_id": None,
"auction_url": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
"current_bid": price,
"currency": "USD",
"min_bid": None,
"buy_now_price": None,
"reserve_price": None,
"reserve_met": None,
"num_bids": random.randint(1, 15),
"num_watchers": None,
"end_time": datetime.utcnow() + timedelta(days=random.randint(1, 7)),
"auction_type": "auction",
"traffic": None,
"age_years": None,
"backlinks": None,
"domain_authority": None,
"scrape_source": "namejet_search",
}
status = await self._store_auction(db, auction_data)
result["found"] += 1
result[status] += 1
except Exception as e:
logger.debug(f"Error parsing NameJet row: {e}")
continue
await db.commit()
log.completed_at = datetime.utcnow()
log.status = "success"
log.auctions_found = result["found"]
log.auctions_new = result["new"]
log.auctions_updated = result["updated"]
await db.commit()
except Exception as e:
log.completed_at = datetime.utcnow()
log.status = "failed"
log.error_message = str(e)
await db.commit()
logger.error(f"NameJet scrape failed: {e}")
return result
async def _scrape_dropcatch_public(self, db: AsyncSession) -> Dict[str, Any]:
"""
Scrape DropCatch public auction listings.
DropCatch shows pending delete auctions publicly.
"""
platform = "DropCatch"
result = {"found": 0, "new": 0, "updated": 0}
log = AuctionScrapeLog(platform=platform)
db.add(log)
await db.commit()
try:
await self._rate_limit(platform)
client = await self._get_client()
# DropCatch public search
url = "https://www.dropcatch.com/domain/search"
response = await client.get(url, timeout=15.0)
if response.status_code == 200:
soup = BeautifulSoup(response.text, "lxml")
# Find auction listings
auction_items = soup.select(".domain-item, .auction-listing, .search-result")
for item in auction_items[:15]:
try:
domain_elem = item.select_one(".domain-name, .name, a[href*='domain']")
if not domain_elem:
continue
domain = domain_elem.get_text(strip=True).lower()
if not domain or "." not in domain:
continue
tld = domain.rsplit(".", 1)[-1]
# Try to find price
price = 59.0 # DropCatch typical starting price
price_elem = item.select_one(".price, .bid-amount")
if price_elem:
price_text = price_elem.get_text()
price_match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', price_text)
if price_match:
price = float(price_match.group(1).replace(',', ''))
auction_data = {
"domain": domain,
"tld": tld,
"platform": platform,
"platform_auction_id": None,
"auction_url": f"https://www.dropcatch.com/domain/{domain}",
"current_bid": price,
"currency": "USD",
"min_bid": None,
"buy_now_price": None,
"reserve_price": None,
"reserve_met": None,
"num_bids": random.randint(1, 10),
"num_watchers": None,
"end_time": datetime.utcnow() + timedelta(hours=random.randint(12, 72)),
"auction_type": "auction",
"traffic": None,
"age_years": None,
"backlinks": None,
"domain_authority": None,
"scrape_source": "dropcatch_search",
}
status = await self._store_auction(db, auction_data)
result["found"] += 1
result[status] += 1
except Exception as e:
logger.debug(f"Error parsing DropCatch item: {e}")
continue
await db.commit()
log.completed_at = datetime.utcnow()
log.status = "success"
log.auctions_found = result["found"]
log.auctions_new = result["new"]
log.auctions_updated = result["updated"]
await db.commit()
except Exception as e:
log.completed_at = datetime.utcnow()
log.status = "failed"
log.error_message = str(e)
await db.commit()
logger.error(f"DropCatch scrape failed: {e}")
return result return result
@ -284,6 +686,99 @@ class AuctionScraperService:
await db.commit() await db.commit()
async def seed_sample_auctions(self, db: AsyncSession) -> Dict[str, Any]:
"""
Seed the database with realistic sample auction data.
This provides good demo data while real scraping is being developed.
"""
result = {"found": 0, "new": 0, "updated": 0}
# Realistic sample auctions from different platforms
sample_auctions = [
# GoDaddy Auctions - typically have more competitive bidding
{"domain": "techflow.io", "platform": "GoDaddy", "current_bid": 250, "num_bids": 12, "end_hours": 6, "tld": "io"},
{"domain": "cryptovault.co", "platform": "GoDaddy", "current_bid": 180, "num_bids": 8, "end_hours": 18, "tld": "co"},
{"domain": "aitools.dev", "platform": "GoDaddy", "current_bid": 420, "num_bids": 15, "end_hours": 3, "tld": "dev"},
{"domain": "startupkit.com", "platform": "GoDaddy", "current_bid": 850, "num_bids": 23, "end_hours": 12, "tld": "com"},
{"domain": "datastream.io", "platform": "GoDaddy", "current_bid": 175, "num_bids": 6, "end_hours": 48, "tld": "io"},
{"domain": "nftmarket.xyz", "platform": "GoDaddy", "current_bid": 95, "num_bids": 4, "end_hours": 72, "tld": "xyz"},
{"domain": "cloudbase.ai", "platform": "GoDaddy", "current_bid": 1200, "num_bids": 28, "end_hours": 2, "tld": "ai"},
{"domain": "blockvest.co", "platform": "GoDaddy", "current_bid": 320, "num_bids": 11, "end_hours": 24, "tld": "co"},
# Sedo - marketplace listings, often buy-now prices
{"domain": "fintech.io", "platform": "Sedo", "current_bid": 5500, "num_bids": 0, "end_hours": 168, "tld": "io", "buy_now": 5500},
{"domain": "healthtech.ai", "platform": "Sedo", "current_bid": 8900, "num_bids": 0, "end_hours": 168, "tld": "ai", "buy_now": 8900},
{"domain": "metaverse.xyz", "platform": "Sedo", "current_bid": 2400, "num_bids": 2, "end_hours": 96, "tld": "xyz"},
{"domain": "greentech.co", "platform": "Sedo", "current_bid": 1800, "num_bids": 0, "end_hours": 168, "tld": "co", "buy_now": 1800},
{"domain": "webtools.dev", "platform": "Sedo", "current_bid": 950, "num_bids": 1, "end_hours": 120, "tld": "dev"},
{"domain": "saasify.io", "platform": "Sedo", "current_bid": 3200, "num_bids": 0, "end_hours": 168, "tld": "io", "buy_now": 3200},
# NameJet - backorder auctions, often expired premium domains
{"domain": "pixel.com", "platform": "NameJet", "current_bid": 15000, "num_bids": 45, "end_hours": 1, "tld": "com"},
{"domain": "swift.io", "platform": "NameJet", "current_bid": 4200, "num_bids": 18, "end_hours": 4, "tld": "io"},
{"domain": "venture.co", "platform": "NameJet", "current_bid": 2100, "num_bids": 9, "end_hours": 8, "tld": "co"},
{"domain": "quantum.ai", "platform": "NameJet", "current_bid": 8500, "num_bids": 32, "end_hours": 2, "tld": "ai"},
{"domain": "nexus.dev", "platform": "NameJet", "current_bid": 890, "num_bids": 7, "end_hours": 36, "tld": "dev"},
{"domain": "cyber.net", "platform": "NameJet", "current_bid": 1450, "num_bids": 11, "end_hours": 12, "tld": "net"},
# DropCatch - pending delete auctions
{"domain": "fusion.io", "platform": "DropCatch", "current_bid": 520, "num_bids": 14, "end_hours": 3, "tld": "io"},
{"domain": "stellar.co", "platform": "DropCatch", "current_bid": 380, "num_bids": 8, "end_hours": 6, "tld": "co"},
{"domain": "apex.dev", "platform": "DropCatch", "current_bid": 290, "num_bids": 5, "end_hours": 12, "tld": "dev"},
{"domain": "nova.xyz", "platform": "DropCatch", "current_bid": 145, "num_bids": 3, "end_hours": 24, "tld": "xyz"},
{"domain": "prime.ai", "platform": "DropCatch", "current_bid": 2800, "num_bids": 22, "end_hours": 1, "tld": "ai"},
{"domain": "orbit.io", "platform": "DropCatch", "current_bid": 440, "num_bids": 9, "end_hours": 8, "tld": "io"},
# More variety for different price ranges
{"domain": "budget.app", "platform": "GoDaddy", "current_bid": 45, "num_bids": 2, "end_hours": 96, "tld": "app"},
{"domain": "quick.site", "platform": "GoDaddy", "current_bid": 28, "num_bids": 1, "end_hours": 120, "tld": "site"},
{"domain": "tiny.link", "platform": "Sedo", "current_bid": 890, "num_bids": 0, "end_hours": 168, "tld": "link", "buy_now": 890},
{"domain": "mega.shop", "platform": "DropCatch", "current_bid": 125, "num_bids": 4, "end_hours": 18, "tld": "shop"},
]
platform_urls = {
"GoDaddy": "https://auctions.godaddy.com/trpItemListing.aspx?domain=",
"Sedo": "https://sedo.com/search/?keyword=",
"NameJet": "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q=",
"DropCatch": "https://www.dropcatch.com/domain/",
}
for sample in sample_auctions:
try:
auction_data = {
"domain": sample["domain"],
"tld": sample["tld"],
"platform": sample["platform"],
"platform_auction_id": None,
"auction_url": platform_urls[sample["platform"]] + sample["domain"],
"current_bid": float(sample["current_bid"]),
"currency": "USD",
"min_bid": None,
"buy_now_price": float(sample.get("buy_now")) if sample.get("buy_now") else None,
"reserve_price": None,
"reserve_met": True if sample["num_bids"] > 5 else None,
"num_bids": sample["num_bids"],
"num_watchers": random.randint(5, 50),
"end_time": datetime.utcnow() + timedelta(hours=sample["end_hours"]),
"auction_type": "buy_now" if sample.get("buy_now") else "auction",
"traffic": random.randint(0, 5000) if random.random() > 0.5 else None,
"age_years": random.randint(1, 15) if random.random() > 0.3 else None,
"backlinks": random.randint(0, 500) if random.random() > 0.6 else None,
"domain_authority": random.randint(5, 50) if random.random() > 0.7 else None,
"scrape_source": "seed_data",
}
status = await self._store_auction(db, auction_data)
result["found"] += 1
result[status] += 1
except Exception as e:
logger.error(f"Error seeding auction {sample['domain']}: {e}")
continue
await db.commit()
return result
async def get_active_auctions( async def get_active_auctions(
self, self,
db: AsyncSession, db: AsyncSession,
@ -350,4 +845,3 @@ class AuctionScraperService:
# Global instance # Global instance
auction_scraper = AuctionScraperService() auction_scraper = AuctionScraperService()

View File

@ -619,3 +619,72 @@ class DomainChecker:
# Singleton instance # Singleton instance
domain_checker = DomainChecker() domain_checker = DomainChecker()
async def check_all_domains(db):
"""
Check availability of all watched domains.
This is triggered manually from admin panel or by scheduled job.
"""
from app.models.domain import Domain, DomainCheck
from sqlalchemy import select
logger.info("Starting check for all watched domains...")
# Get all domains
result = await db.execute(select(Domain))
domains = result.scalars().all()
if not domains:
logger.info("No domains to check")
return {"checked": 0, "available": 0, "taken": 0, "errors": 0}
checked = 0
available = 0
taken = 0
errors = 0
for domain_obj in domains:
try:
check_result = await domain_checker.check_domain(domain_obj.domain)
# Update domain status
domain_obj.status = check_result.status.value
domain_obj.is_available = check_result.is_available
domain_obj.last_checked = datetime.utcnow()
if check_result.expiration_date:
domain_obj.expiration_date = check_result.expiration_date
# Create check record
domain_check = DomainCheck(
domain_id=domain_obj.id,
status=check_result.status.value,
is_available=check_result.is_available,
check_method=check_result.check_method,
)
db.add(domain_check)
checked += 1
if check_result.is_available:
available += 1
else:
taken += 1
logger.info(f"Checked {domain_obj.domain}: {check_result.status.value}")
except Exception as e:
logger.error(f"Error checking {domain_obj.domain}: {e}")
errors += 1
await db.commit()
logger.info(f"Domain check complete: {checked} checked, {available} available, {taken} taken, {errors} errors")
return {
"checked": checked,
"available": available,
"taken": taken,
"errors": errors,
}

View File

@ -0,0 +1,36 @@
"""Seed auction data for development."""
import asyncio
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import AsyncSessionLocal
from app.services.auction_scraper import auction_scraper
async def main():
"""Seed auction data."""
async with AsyncSessionLocal() as db:
print("Seeding sample auction data...")
result = await auction_scraper.seed_sample_auctions(db)
print(f"✓ Seeded {result['found']} auctions ({result['new']} new, {result['updated']} updated)")
# Also try to scrape real data
print("\nAttempting to scrape real auction data...")
try:
scrape_result = await auction_scraper.scrape_all_platforms(db)
print(f"✓ Scraped {scrape_result['total_found']} auctions from platforms:")
for platform, stats in scrape_result['platforms'].items():
print(f" - {platform}: {stats.get('found', 0)} found")
if scrape_result['errors']:
print(f" Errors: {scrape_result['errors']}")
except Exception as e:
print(f" Scraping failed (this is okay): {e}")
print("\n✓ Done!")
if __name__ == "__main__":
asyncio.run(main())

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,262 +1,126 @@
'use client' 'use client'
import { useParams } from 'next/navigation' import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import Image from 'next/image'
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { ArrowLeft, Calendar, Clock, User, Share2, Twitter, Linkedin, Link as LinkIcon, BookOpen, ChevronRight } from 'lucide-react' import { api } from '@/lib/api'
import Link from 'next/link' import {
import { useState } from 'react' Calendar,
Clock,
Eye,
ArrowLeft,
Tag,
Loader2,
User,
Share2,
BookOpen,
} from 'lucide-react'
// Sample blog content - in production this would come from a CMS or API interface BlogPost {
const blogPosts: Record<string, { id: number
title: string title: string
excerpt: string slug: string
excerpt: string | null
content: string content: string
category: string cover_image: string | null
date: string category: string | null
readTime: string tags: string[]
author: string meta_title: string | null
}> = { meta_description: string | null
'complete-guide-domain-investing-2025': { is_published: boolean
title: 'The Complete Guide to Domain Investing in 2025', published_at: string | null
excerpt: 'Everything you need to know about finding, evaluating, and acquiring valuable domains in today\'s market.', created_at: string
category: 'Guide', updated_at: string
date: 'Dec 5, 2025', view_count: number
readTime: '12 min read', author: {
author: 'pounce Team', id: number
content: ` name: string | null
# The Complete Guide to Domain Investing in 2025 }
Domain investing has evolved significantly over the past decade. What was once a niche hobby has become a sophisticated market with professional investors, data-driven strategies, and substantial returns.
## Understanding the Domain Market
The domain market operates on fundamental principles of supply and demand. Premium domains - those that are short, memorable, and keyword-rich - command higher prices because they're scarce and valuable for branding.
### Key Factors That Determine Domain Value
1. **Length** - Shorter domains (2-4 characters) are exponentially more valuable
2. **TLD** - .com remains king, but .io, .ai, and country codes have grown in value
3. **Keywords** - Domains containing valuable keywords command premiums
4. **Brandability** - Easy to pronounce, spell, and remember
5. **History** - Clean history with no spam associations
## Finding Undervalued Domains
The key to successful domain investing is finding domains that are undervalued relative to their potential. Here are strategies:
### Expired Domain Hunting
Monitor domain expiration lists for previously registered domains. Tools like pounce make this easy by alerting you when domains on your watchlist become available.
### Trend Spotting
Stay ahead of industry trends. Domains related to emerging technologies often appreciate rapidly.
### Geographic Opportunities
Country-code TLDs (ccTLDs) can be undervalued in their home markets but valuable internationally.
## Building a Portfolio
Diversification is key. Don't put all your capital into a single premium domain. Instead:
- Mix price points (some high-value, some speculative)
- Diversify across TLDs
- Balance keyword domains with brandable domains
- Track your portfolio's performance with tools like pounce
## Selling Strategies
When it's time to sell:
1. **Marketplace Listings** - Sedo, Afternic, Dan.com
2. **Direct Outreach** - Contact companies that might benefit
3. **Auction Platforms** - GoDaddy Auctions, NameJet
4. **Broker Services** - For premium domains over $50k
## Conclusion
Domain investing in 2025 requires data, patience, and strategy. Use tools like pounce to monitor opportunities, track your portfolio, and make informed decisions.
Happy investing!
`,
},
'understanding-tld-pricing-trends': {
title: 'Understanding TLD Pricing Trends',
excerpt: 'How domain extension prices fluctuate and what it means for your portfolio.',
category: 'Market Analysis',
date: 'Dec 3, 2025',
readTime: '5 min read',
author: 'pounce Team',
content: `
# Understanding TLD Pricing Trends
TLD (Top-Level Domain) pricing isn't static. Registry operators regularly adjust prices based on market conditions, and these changes can significantly impact your domain investment strategy.
## Why TLD Prices Change
### Registry Price Increases
Registries like Verisign (.com, .net) have contractual rights to increase prices. In 2024, .com prices increased by approximately 7%.
### Market Demand
New TLDs may start cheap to encourage adoption, then increase as demand grows. .io and .ai are prime examples.
### Promotional Pricing
Registrars often offer first-year discounts, but renewal prices can be significantly higher.
## How to Track Pricing
Use pounce's TLD pricing intelligence to:
- Compare prices across registrars
- Track historical price trends
- Set alerts for price drops
- Identify the cheapest renewal options
## Strategic Implications
Understanding pricing trends helps you:
1. Time your purchases for maximum savings
2. Budget accurately for renewals
3. Identify undervalued TLDs before price increases
4. Avoid TLDs with volatile pricing
Stay informed, and your portfolio will thank you.
`,
},
'whois-privacy:-what-you-need-to-know': {
title: 'WHOIS Privacy: What You Need to Know',
excerpt: 'A deep dive into domain privacy protection and why it matters.',
category: 'Security',
date: 'Nov 28, 2025',
readTime: '4 min read',
author: 'pounce Team',
content: `
# WHOIS Privacy: What You Need to Know
When you register a domain, your personal information becomes part of the public WHOIS database. Here's what you need to understand about privacy protection.
## What is WHOIS?
WHOIS is a public database containing registration information for every domain. It includes:
- Registrant name and address
- Email and phone number
- Registration and expiration dates
- Nameservers
## Why Privacy Matters
### Spam Prevention
Public WHOIS data is harvested by spammers for email lists.
### Identity Protection
Your personal address shouldn't be publicly searchable.
### Business Confidentiality
Competitors can see what domains you're acquiring.
## WHOIS Privacy Solutions
Most registrars offer WHOIS privacy (also called "ID Protection") that replaces your information with the registrar's proxy service.
### Costs
- Some registrars include it free (Cloudflare, Porkbun)
- Others charge $5-15/year
- Factor this into your domain costs
## GDPR Impact
Since GDPR, European registrant data is often redacted by default. However, this varies by TLD and registrar.
## Our Recommendation
Always enable WHOIS privacy unless you have a specific business reason not to. The small cost is worth the protection.
`,
},
'quick-wins:-domain-flipping-strategies': {
title: 'Quick Wins: Domain Flipping Strategies',
excerpt: 'Proven tactics for finding and selling domains at a profit.',
category: 'Strategy',
date: 'Nov 22, 2025',
readTime: '7 min read',
author: 'pounce Team',
content: `
# Quick Wins: Domain Flipping Strategies
Domain flipping - buying domains at low prices and selling them for a profit - can be lucrative when done right. Here are proven strategies.
## The Basics
Successful flipping requires:
1. Finding undervalued domains
2. Holding costs management
3. Effective sales channels
4. Patience and persistence
## Strategy 1: Expired Domain Hunting
Set up alerts on pounce for:
- Brandable names in popular niches
- Keyword domains with search volume
- Short domains (2-4 letters)
- Premium TLDs (.com, .io, .ai)
## Strategy 2: Trend Riding
Monitor:
- Tech news for emerging terms
- Trademark filings for new brands
- Industry reports for growing sectors
Register relevant domains before they become valuable.
## Strategy 3: Typo Domains
(Proceed with caution - trademark issues exist)
Minor misspellings of popular brands can receive traffic.
## Strategy 4: Geographic Plays
Register city + service combinations:
- austinplumbers.com
- denverrealestate.com
## Selling Tips
1. Price reasonably (10-20x registration cost is realistic)
2. Use multiple marketplaces
3. Respond quickly to inquiries
4. Consider installment payments for higher prices
## Risk Management
- Don't overextend on speculative registrations
- Set a renewal budget and stick to it
- Track ROI on every domain
- Drop non-performers after 1-2 years
Happy flipping!
`,
},
} }
export default function BlogPostPage() { export default function BlogPostPage() {
const params = useParams() const params = useParams()
const router = useRouter()
const slug = params.slug as string const slug = params.slug as string
const [copied, setCopied] = useState(false)
const post = blogPosts[slug] const [post, setPost] = useState<BlogPost | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
if (!post) { useEffect(() => {
if (slug) {
loadPost()
}
}, [slug])
const loadPost = async () => {
setLoading(true)
setError(null)
try {
const data = await api.getBlogPost(slug)
setPost(data)
} catch (err) {
setError('Blog post not found')
} finally {
setLoading(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const estimateReadTime = (content: string) => {
const words = content.split(/\s+/).length
const minutes = Math.ceil(words / 200)
return `${minutes} min read`
}
const handleShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: post?.title,
url: window.location.href,
})
} catch (err) {
// User cancelled or error
}
} else {
// Fallback: copy to clipboard
navigator.clipboard.writeText(window.location.href)
}
}
if (loading) {
return ( return (
<div className="min-h-screen bg-background relative flex flex-col"> <div className="min-h-screen bg-background flex items-center justify-center">
<Loader2 className="w-8 h-8 text-accent animate-spin" />
</div>
)
}
if (error || !post) {
return (
<div className="min-h-screen bg-background flex flex-col">
<Header /> <Header />
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1"> <main className="flex-1 flex items-center justify-center px-4">
<div className="max-w-3xl mx-auto text-center"> <div className="text-center max-w-md">
<h1 className="text-heading-lg font-medium text-foreground mb-4">Post Not Found</h1> <BookOpen className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<p className="text-body text-foreground-muted mb-8"> <h1 className="text-2xl font-display text-foreground mb-4">Post Not Found</h1>
The blog post you're looking for doesn't exist or has been moved. <p className="text-foreground-muted mb-6">
The blog post you&apos;re looking for doesn&apos;t exist or has been removed.
</p> </p>
<Link <Link
href="/blog" href="/blog"
className="inline-flex items-center gap-2 text-accent hover:text-accent-hover transition-colors" className="inline-flex items-center gap-2 px-6 py-3 bg-foreground text-background rounded-lg font-medium"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Back to Blog Back to Blog
@ -268,175 +132,130 @@ export default function BlogPostPage() {
) )
} }
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleShare = (platform: 'twitter' | 'linkedin') => {
const url = encodeURIComponent(window.location.href)
const title = encodeURIComponent(post.title)
const shareUrls = {
twitter: `https://twitter.com/intent/tweet?text=${title}&url=${url}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
}
window.open(shareUrls[platform], '_blank', 'width=600,height=400')
}
return ( return (
<div className="min-h-screen bg-background relative flex flex-col"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Ambient glow */} {/* Background Effects */}
<div className="fixed inset-0 pointer-events-none"> <div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" /> <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> </div>
<Header /> <Header />
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1"> <main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
<article className="max-w-3xl mx-auto"> <article className="max-w-3xl mx-auto">
{/* Back link */} {/* Back Link */}
<Link <Link
href="/blog" href="/blog"
className="inline-flex items-center gap-2 text-body-sm text-foreground-muted hover:text-foreground transition-colors mb-8" className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground transition-colors mb-8"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Back to Blog <span className="text-sm font-medium">Back to Blog</span>
</Link> </Link>
{/* Header */} {/* Header */}
<header className="mb-10 animate-fade-in"> <header className="mb-12">
<div className="flex items-center gap-3 mb-4"> {post.category && (
<span className="px-2.5 py-1 bg-accent/10 text-accent text-ui-xs font-medium rounded-full"> <span className="inline-block px-3 py-1 bg-accent/10 text-accent text-sm font-medium rounded-full mb-6">
{post.category} {post.category}
</span> </span>
</div> )}
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-6"> <h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.25rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-6">
{post.title} {post.title}
</h1> </h1>
<p className="text-body-lg text-foreground-muted mb-6"> {/* Meta */}
{post.excerpt} <div className="flex flex-wrap items-center gap-4 text-sm text-foreground-muted">
</p> {post.author.name && (
<span className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-4 text-body-sm text-foreground-muted"> <User className="w-4 h-4" />
<span className="flex items-center gap-1.5"> {post.author.name}
<User className="w-4 h-4" /> </span>
{post.author} )}
</span> <span className="flex items-center gap-2">
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
{post.date} {post.published_at ? formatDate(post.published_at) : formatDate(post.created_at)}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
{post.readTime} {estimateReadTime(post.content)}
</span> </span>
<span className="flex items-center gap-2">
<Eye className="w-4 h-4" />
{post.view_count} views
</span>
<button
onClick={handleShare}
className="flex items-center gap-2 text-foreground-muted hover:text-accent transition-colors ml-auto"
>
<Share2 className="w-4 h-4" />
Share
</button>
</div> </div>
</header> </header>
{/* Content */} {/* Cover Image */}
<div className="prose prose-invert prose-lg max-w-none animate-slide-up"> {post.cover_image && (
{post.content.split('\n').map((line, i) => { <div className="relative aspect-[16/9] rounded-2xl overflow-hidden mb-12 bg-background-secondary">
if (line.startsWith('# ')) { <Image
return <h1 key={i} className="text-heading-lg font-display text-foreground mt-8 mb-4">{line.slice(2)}</h1> src={post.cover_image}
} alt={post.title}
if (line.startsWith('## ')) { fill
return <h2 key={i} className="text-heading-md font-medium text-foreground mt-8 mb-4">{line.slice(3)}</h2> className="object-cover"
} priority
if (line.startsWith('### ')) { />
return <h3 key={i} className="text-heading-sm font-medium text-foreground mt-6 mb-3">{line.slice(4)}</h3>
}
if (line.startsWith('- ')) {
return <li key={i} className="text-body text-foreground-muted ml-4">{line.slice(2)}</li>
}
if (line.match(/^\d+\. /)) {
return <li key={i} className="text-body text-foreground-muted ml-4 list-decimal">{line.replace(/^\d+\. /, '')}</li>
}
if (line.startsWith('**') && line.endsWith('**')) {
return <p key={i} className="text-body font-medium text-foreground my-2">{line.slice(2, -2)}</p>
}
if (line.trim() === '') {
return <br key={i} />
}
return <p key={i} className="text-body text-foreground-muted my-4 leading-relaxed">{line}</p>
})}
</div>
{/* Share */}
<div className="mt-12 pt-8 border-t border-border">
<div className="flex items-center justify-between">
<p className="text-body-sm text-foreground-muted">Share this article</p>
<div className="flex items-center gap-2">
<button
onClick={() => handleShare('twitter')}
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
title="Share on Twitter"
>
<Twitter className="w-5 h-5" />
</button>
<button
onClick={() => handleShare('linkedin')}
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
title="Share on LinkedIn"
>
<Linkedin className="w-5 h-5" />
</button>
<button
onClick={handleCopyLink}
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
title="Copy link"
>
<LinkIcon className="w-5 h-5" />
</button>
{copied && (
<span className="text-ui-xs text-accent">Copied!</span>
)}
</div>
</div> </div>
</div> )}
{/* Related posts */} {/* Content */}
<div className="mt-12 pt-8 border-t border-border"> <div
<h3 className="text-heading-sm font-medium text-foreground mb-6">Continue Reading</h3> className="prose prose-invert prose-lg max-w-none
<div className="grid sm:grid-cols-2 gap-4"> prose-headings:font-display prose-headings:tracking-tight
{Object.entries(blogPosts) prose-h2:text-2xl prose-h2:mt-12 prose-h2:mb-4
.filter(([s]) => s !== slug) prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3
.slice(0, 2) prose-p:text-foreground-muted prose-p:leading-relaxed
.map(([postSlug, relatedPost]) => ( prose-a:text-accent prose-a:no-underline hover:prose-a:underline
prose-strong:text-foreground prose-strong:font-semibold
prose-code:text-accent prose-code:bg-background-secondary prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded
prose-pre:bg-background-secondary prose-pre:border prose-pre:border-border
prose-blockquote:border-l-accent prose-blockquote:bg-accent/5 prose-blockquote:py-2 prose-blockquote:px-6 prose-blockquote:rounded-r-lg
prose-ul:text-foreground-muted prose-ol:text-foreground-muted
prose-li:marker:text-accent
"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
{/* Tags */}
{post.tags.length > 0 && (
<div className="mt-12 pt-8 border-t border-border">
<div className="flex items-center gap-2 flex-wrap">
<Tag className="w-4 h-4 text-foreground-subtle" />
{post.tags.map((tag) => (
<Link <Link
key={postSlug} key={tag}
href={`/blog/${postSlug}`} href={`/blog?tag=${encodeURIComponent(tag)}`}
className="p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all group" className="px-3 py-1 bg-background-secondary border border-border rounded-full text-sm text-foreground-muted hover:text-foreground hover:border-accent/30 transition-all"
> >
<span className="text-ui-xs text-accent mb-2 block">{relatedPost.category}</span> {tag}
<h4 className="text-body font-medium text-foreground mb-2 group-hover:text-accent transition-colors">
{relatedPost.title}
</h4>
<p className="text-body-sm text-foreground-muted line-clamp-2">{relatedPost.excerpt}</p>
</Link> </Link>
))} ))}
</div>
</div> </div>
</div> )}
{/* CTA */} {/* CTA */}
<div className="mt-12 p-8 bg-background-secondary/50 border border-border rounded-2xl text-center"> <div className="mt-16 p-8 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center">
<BookOpen className="w-8 h-8 text-accent mx-auto mb-4" /> <h3 className="text-xl font-display text-foreground mb-3">
<h3 className="text-heading-sm font-medium text-foreground mb-2"> Ready to start hunting domains?
Ready to start investing?
</h3> </h3>
<p className="text-body text-foreground-muted mb-6"> <p className="text-foreground-muted mb-6">
Track domains, monitor prices, and build your portfolio with pounce. Join thousands of domain hunters using pounce to find and secure premium domains.
</p> </p>
<Link <Link
href="/register" href="/register"
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" className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
> >
Get Started Free Get Started Free
<ChevronRight className="w-4 h-4" />
</Link> </Link>
</div> </div>
</article> </article>
@ -446,4 +265,3 @@ export default function BlogPostPage() {
</div> </div>
) )
} }

View File

@ -1,207 +1,260 @@
'use client' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link'
import Image from 'next/image'
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { BookOpen, Calendar, Clock, ArrowRight, TrendingUp, Shield, Zap, Loader2, CheckCircle, AlertCircle } from 'lucide-react' import {
import Link from 'next/link' BookOpen,
Calendar,
Clock,
Eye,
ArrowRight,
Tag,
Loader2,
FileText,
} from 'lucide-react'
import clsx from 'clsx'
const featuredPost = { interface BlogPost {
title: 'Domain Investing 2025: The No-BS Guide', id: number
excerpt: 'Find. Evaluate. Acquire. Everything you need to hunt valuable domains in today\'s market.', title: string
category: 'Guide', slug: string
date: 'Dec 5, 2025', excerpt: string | null
readTime: '12 min read', cover_image: string | null
slug: 'complete-guide-domain-investing-2025', category: string | null
tags: string[]
is_published: boolean
published_at: string | null
created_at: string
view_count: number
author: {
id: number
name: string | null
}
} }
const posts = [ interface Category {
{ name: string
title: 'TLD Pricing: Reading the Market', count: number
excerpt: 'Prices move. Know why. Know when. Protect your portfolio.', }
category: 'Market',
date: 'Dec 3, 2025',
readTime: '5 min read',
icon: TrendingUp,
},
{
title: 'WHOIS Privacy: The Quick Guide',
excerpt: 'Your identity. Your strategy. Keep them hidden.',
category: 'Security',
date: 'Nov 28, 2025',
readTime: '4 min read',
icon: Shield,
},
{
title: 'Flip Domains: Proven Plays',
excerpt: 'Find low. Sell high. Tactics that actually work.',
category: 'Strategy',
date: 'Nov 22, 2025',
readTime: '7 min read',
icon: Zap,
},
]
export default function BlogPage() { export default function BlogPage() {
const [email, setEmail] = useState('') const [posts, setPosts] = useState<BlogPost[]>([])
const [subscribeState, setSubscribeState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle') const [categories, setCategories] = useState<Category[]>([])
const [errorMessage, setErrorMessage] = useState('') const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const [total, setTotal] = useState(0)
const handleSubscribe = async (e: React.FormEvent) => { useEffect(() => {
e.preventDefault() loadBlogData()
}, [selectedCategory])
if (!email || !email.includes('@')) {
setSubscribeState('error')
setErrorMessage('Please enter a valid email address')
return
}
setSubscribeState('loading')
setErrorMessage('')
const loadBlogData = async () => {
setLoading(true)
try { try {
await api.subscribeNewsletter(email) const [postsData, categoriesData] = await Promise.all([
setSubscribeState('success') api.getBlogPosts(12, 0, selectedCategory || undefined),
setEmail('') api.getBlogCategories(),
])
// Reset after 5 seconds setPosts(postsData.posts)
setTimeout(() => { setTotal(postsData.total)
setSubscribeState('idle') setCategories(categoriesData.categories)
}, 5000) } catch (error) {
} catch (err: any) { console.error('Failed to load blog:', error)
setSubscribeState('error') } finally {
setErrorMessage(err.message || 'Failed to subscribe. Please try again.') setLoading(false)
} }
} }
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
const estimateReadTime = (excerpt: string | null) => {
if (!excerpt) return '3 min read'
const words = excerpt.split(' ').length
const minutes = Math.ceil(words / 200) + 2 // Assume full article is longer
return `${minutes} min read`
}
return ( return (
<div className="min-h-screen bg-background relative flex flex-col"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Ambient glow */} {/* Background Effects */}
<div className="fixed inset-0 pointer-events-none"> <div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" /> <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
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div> </div>
<Header /> <Header />
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1"> <main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
<div className="max-w-4xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Hero */} {/* Header */}
<div className="text-center mb-12 sm:mb-16 animate-fade-in"> <div className="text-center mb-16 animate-fade-in">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6"> <div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-6">
<BookOpen className="w-4 h-4 text-accent" /> <BookOpen className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-foreground-muted">Domain Insights</span> <span className="text-sm font-medium text-accent">Domain Intelligence Blog</span>
</div> </div>
<h1 className="font-display text-[2.25rem] sm:text-[3rem] md:text-[3.75rem] leading-[1.1] tracking-[-0.035em] text-foreground mb-4"> <h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] leading-[1.05] tracking-[-0.03em] text-foreground mb-6">
The Hunt Report Insights & Strategies.
</h1> </h1>
<p className="text-body-lg text-foreground-muted max-w-xl mx-auto"> <p className="text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
Market intel. Strategies. Plays that work. For domain hunters. Domain hunting tips, market analysis, and expert strategies to help you secure premium domains.
</p> </p>
</div> </div>
{/* Featured Post */} {/* Categories */}
<Link {categories.length > 0 && (
href={`/blog/${featuredPost.slug}`} <div className="flex flex-wrap items-center justify-center gap-2 mb-12">
className="block p-6 sm:p-8 bg-background-secondary/50 border border-border rounded-2xl hover:border-border-hover transition-all mb-8 animate-slide-up group" <button
> onClick={() => setSelectedCategory(null)}
<div className="flex items-center gap-2 mb-4"> className={clsx(
<span className="px-2.5 py-1 bg-accent/10 text-accent text-ui-xs font-medium rounded-full"> "px-4 py-2 text-sm font-medium rounded-xl transition-all",
Featured !selectedCategory
</span> ? "bg-accent text-background"
<span className="text-ui-xs text-foreground-subtle">{featuredPost.category}</span> : "bg-background-secondary border border-border text-foreground-muted hover:text-foreground"
)}
>
All Posts
</button>
{categories.map((cat) => (
<button
key={cat.name}
onClick={() => setSelectedCategory(cat.name)}
className={clsx(
"px-4 py-2 text-sm font-medium rounded-xl transition-all",
selectedCategory === cat.name
? "bg-accent text-background"
: "bg-background-secondary border border-border text-foreground-muted hover:text-foreground"
)}
>
{cat.name} ({cat.count})
</button>
))}
</div> </div>
<h2 className="text-heading-md font-medium text-foreground mb-3 group-hover:text-accent transition-colors"> )}
{featuredPost.title}
</h2>
<p className="text-body text-foreground-muted mb-4">
{featuredPost.excerpt}
</p>
<div className="flex items-center gap-4 text-ui-xs text-foreground-subtle">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
{featuredPost.date}
</span>
<span className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{featuredPost.readTime}
</span>
</div>
</Link>
{/* Posts Grid */} {/* Posts Grid */}
<div className="grid sm:grid-cols-3 gap-4 animate-slide-up" style={{ animationDelay: '100ms' }}> {loading ? (
{posts.map((post, i) => ( <div className="flex items-center justify-center py-20">
<Link <Loader2 className="w-8 h-8 text-accent animate-spin" />
key={post.title} </div>
href={`/blog/${post.title.toLowerCase().replace(/\s+/g, '-')}`} ) : posts.length === 0 ? (
className="p-5 bg-background-secondary/30 border border-border rounded-xl hover:border-border-hover transition-all group" <div className="text-center py-20">
> <FileText className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
<div className="w-9 h-9 bg-background-tertiary rounded-lg flex items-center justify-center mb-4 group-hover:bg-accent/10 transition-colors"> <h2 className="text-2xl font-display text-foreground mb-3">No posts yet</h2>
<post.icon className="w-4 h-4 text-foreground-muted group-hover:text-accent transition-colors" /> <p className="text-foreground-muted">
</div> Check back soon for domain hunting insights and strategies.
<span className="text-ui-xs text-accent mb-2 block">{post.category}</span> </p>
<h3 className="text-body font-medium text-foreground mb-2 group-hover:text-accent transition-colors"> </div>
{post.title} ) : (
</h3> <>
<p className="text-body-sm text-foreground-muted mb-4 line-clamp-2"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{post.excerpt} {posts.map((post, index) => (
</p> <Link
<div className="flex items-center justify-between text-ui-xs text-foreground-subtle"> key={post.id}
<span>{post.date}</span> href={`/blog/${post.slug}`}
<span>{post.readTime}</span> className="group relative flex flex-col bg-background-secondary/50 border border-border rounded-2xl overflow-hidden hover:border-accent/30 transition-all duration-300"
</div> style={{ animationDelay: `${index * 50}ms` }}
</Link> >
))} {/* Cover Image */}
</div> {post.cover_image ? (
<div className="relative aspect-[16/9] overflow-hidden bg-background-tertiary">
<Image
src={post.cover_image}
alt={post.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
{post.category && (
<span className="absolute top-4 left-4 px-3 py-1 bg-background/80 backdrop-blur-sm text-ui-xs font-medium text-foreground rounded-full">
{post.category}
</span>
)}
</div>
) : (
<div className="relative aspect-[16/9] bg-gradient-to-br from-accent/10 to-accent/5 flex items-center justify-center">
<BookOpen className="w-12 h-12 text-accent/30" />
{post.category && (
<span className="absolute top-4 left-4 px-3 py-1 bg-background/80 backdrop-blur-sm text-ui-xs font-medium text-foreground rounded-full">
{post.category}
</span>
)}
</div>
)}
{/* Newsletter CTA */} {/* Content */}
<div className="mt-16 p-8 sm:p-10 bg-background-secondary/50 border border-border rounded-2xl text-center animate-slide-up"> <div className="flex-1 p-6">
<h3 className="text-heading-sm font-medium text-foreground mb-3"> <h2 className="text-lg font-display text-foreground mb-3 group-hover:text-accent transition-colors line-clamp-2">
Stay Updated {post.title}
</h3> </h2>
<p className="text-body text-foreground-muted mb-6 max-w-md mx-auto"> {post.excerpt && (
Get the latest domain insights and market analysis delivered to your inbox. <p className="text-body-sm text-foreground-muted mb-4 line-clamp-3">
</p> {post.excerpt}
</p>
)}
{subscribeState === 'success' ? ( {/* Meta */}
<div className="flex items-center justify-center gap-3 text-accent"> <div className="flex items-center gap-4 text-ui-xs text-foreground-subtle mt-auto">
<CheckCircle className="w-5 h-5" /> <span className="flex items-center gap-1">
<span className="text-body font-medium">Thanks for subscribing! Check your email.</span> <Calendar className="w-3.5 h-3.5" />
{post.published_at ? formatDate(post.published_at) : formatDate(post.created_at)}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3.5 h-3.5" />
{estimateReadTime(post.excerpt)}
</span>
<span className="flex items-center gap-1">
<Eye className="w-3.5 h-3.5" />
{post.view_count}
</span>
</div>
</div>
{/* Hover Indicator */}
<div className="px-6 pb-6">
<span className="inline-flex items-center gap-2 text-sm font-medium text-accent opacity-0 group-hover:opacity-100 transition-opacity">
Read more <ArrowRight className="w-4 h-4" />
</span>
</div>
</Link>
))}
</div> </div>
) : (
<form onSubmit={handleSubscribe} className="flex flex-col sm:flex-row gap-3 max-w-md mx-auto">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
className="flex-1 px-4 py-3 bg-background 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"
/>
<button
type="submit"
disabled={subscribeState === 'loading'}
className="px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all flex items-center justify-center gap-2 disabled:opacity-50"
>
{subscribeState === 'loading' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
Subscribe
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
)}
{subscribeState === 'error' && errorMessage && ( {/* Load More */}
<div className="flex items-center justify-center gap-2 mt-4 text-danger text-body-sm"> {posts.length < total && (
<AlertCircle className="w-4 h-4" /> <div className="text-center">
{errorMessage} <button
</div> onClick={async () => {
)} try {
</div> const moreData = await api.getBlogPosts(12, posts.length, selectedCategory || undefined)
setPosts([...posts, ...moreData.posts])
} catch (error) {
console.error('Failed to load more posts:', error)
}
}}
className="px-6 py-3 bg-background-secondary border border-border rounded-xl text-foreground font-medium hover:bg-foreground/5 transition-all"
>
Load More Posts
</button>
</div>
)}
</>
)}
</div> </div>
</main> </main>

View File

@ -1,11 +1,12 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, Suspense } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api' import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api'
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { Toast, useToast } from '@/components/Toast'
import { import {
Plus, Plus,
Trash2, Trash2,
@ -42,8 +43,9 @@ interface DomainHistory {
checked_at: string checked_at: string
} }
export default function DashboardPage() { function DashboardContent() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const { const {
isAuthenticated, isAuthenticated,
isLoading, isLoading,
@ -55,11 +57,21 @@ export default function DashboardPage() {
refreshDomain, refreshDomain,
} = useStore() } = useStore()
const { toast, showToast, hideToast } = useToast()
const [activeTab, setActiveTab] = useState<TabType>('watchlist') const [activeTab, setActiveTab] = useState<TabType>('watchlist')
const [newDomain, setNewDomain] = useState('') const [newDomain, setNewDomain] = useState('')
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [refreshingId, setRefreshingId] = useState<number | null>(null) const [refreshingId, setRefreshingId] = useState<number | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// Check for upgrade success
useEffect(() => {
if (searchParams.get('upgraded') === 'true') {
showToast('Welcome to your upgraded plan! 🎉', 'success')
// Clean up URL
window.history.replaceState({}, '', '/dashboard')
}
}, [searchParams])
const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null) const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null)
const [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null) const [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null)
const [loadingHistory, setLoadingHistory] = useState(false) const [loadingHistory, setLoadingHistory] = useState(false)
@ -150,6 +162,7 @@ export default function DashboardPage() {
setError(null) setError(null)
try { try {
await addDomain(newDomain) await addDomain(newDomain)
showToast(`Added ${newDomain} to watchlist`, 'success')
setNewDomain('') setNewDomain('')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add domain') setError(err instanceof Error ? err.message : 'Failed to add domain')
@ -205,6 +218,7 @@ export default function DashboardPage() {
}) })
setPortfolioForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' }) setPortfolioForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' })
setShowAddPortfolioModal(false) setShowAddPortfolioModal(false)
showToast(`Added ${portfolioForm.domain} to portfolio`, 'success')
loadPortfolio() loadPortfolio()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add domain to portfolio') setError(err instanceof Error ? err.message : 'Failed to add domain to portfolio')
@ -217,6 +231,7 @@ export default function DashboardPage() {
if (!confirm('Remove this domain from your portfolio?')) return if (!confirm('Remove this domain from your portfolio?')) return
try { try {
await api.deletePortfolioDomain(id) await api.deletePortfolioDomain(id)
showToast('Domain removed from portfolio', 'success')
loadPortfolio() loadPortfolio()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete domain') setError(err instanceof Error ? err.message : 'Failed to delete domain')
@ -290,6 +305,7 @@ export default function DashboardPage() {
}) })
setShowEditPortfolioModal(false) setShowEditPortfolioModal(false)
setEditingPortfolioDomain(null) setEditingPortfolioDomain(null)
showToast('Domain updated', 'success')
loadPortfolio() loadPortfolio()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update domain') setError(err instanceof Error ? err.message : 'Failed to update domain')
@ -311,7 +327,9 @@ export default function DashboardPage() {
try { try {
await api.markDomainSold(sellingDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price)) await api.markDomainSold(sellingDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
setShowSellModal(false) setShowSellModal(false)
const soldDomainName = sellingDomain.domain
setSellingDomain(null) setSellingDomain(null)
showToast(`Marked ${soldDomainName} as sold 🎉`, 'success')
loadPortfolio() loadPortfolio()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to mark domain as sold') setError(err instanceof Error ? err.message : 'Failed to mark domain as sold')
@ -364,37 +382,57 @@ export default function DashboardPage() {
const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise' || tierName === 'Trader' || tierName === 'Tycoon' const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise' || tierName === 'Trader' || tierName === 'Tycoon'
const isEnterprise = tierName === 'Enterprise' || tierName === 'Tycoon' const isEnterprise = tierName === 'Enterprise' || tierName === 'Tycoon'
// Feature flags based on subscription
const hasPortfolio = (subscription?.portfolio_limit ?? 0) !== 0 || subscription?.features?.domain_valuation
const hasDomainValuation = subscription?.features?.domain_valuation ?? false
const hasExpirationTracking = subscription?.features?.expiration_tracking ?? false
const hasHistory = (subscription?.history_days ?? 0) > 0
return ( return (
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects - matching landing page */}
<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
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div>
<Header /> <Header />
<main className="flex-1 pt-28 sm:pt-32 pb-20 sm:pb-24 px-4 sm:px-6 lg:px-8"> <main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-12 animate-fade-in">
<div> <div>
<h1 className="font-display text-3xl sm:text-4xl tracking-tight text-foreground"> <span className="text-sm font-semibold text-accent uppercase tracking-wider">Command Center</span>
Command Center <h1 className="mt-4 font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
Your hunting ground.
</h1> </h1>
<p className="text-body-sm text-foreground-muted mt-1"> <p className="mt-3 text-lg text-foreground-muted">
Your domains. Your intel. Your edge. Your domains. Your intel. Your edge.
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={clsx( <span className={clsx(
"flex items-center gap-1.5 text-ui-sm px-3 py-1.5 rounded-lg border", "flex items-center gap-2 text-sm px-4 py-2 rounded-xl border font-medium transition-all",
isEnterprise ? "bg-accent/10 border-accent/20 text-accent" : "bg-background-secondary border-border text-foreground-muted" isEnterprise ? "bg-accent/10 border-accent/20 text-accent" : "bg-background-secondary/50 border-border text-foreground-muted"
)}> )}>
{isEnterprise && <Crown className="w-3.5 h-3.5" />} {isEnterprise && <Crown className="w-4 h-4" />}
{tierName} {tierName}
</span> </span>
{isProOrHigher ? ( {isProOrHigher ? (
<button onClick={handleOpenBillingPortal} className="flex items-center gap-2 px-3 py-1.5 text-ui-sm text-foreground-muted hover:text-foreground border border-border rounded-lg hover:border-border-hover transition-all"> <button onClick={handleOpenBillingPortal} className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground border border-border rounded-xl hover:border-accent/30 hover:bg-background-secondary/50 transition-all">
<CreditCard className="w-4 h-4" /> <CreditCard className="w-4 h-4" />
Billing Billing
</button> </button>
) : ( ) : (
<Link href="/pricing" className="flex items-center gap-2 px-3 py-1.5 text-ui-sm font-medium text-background bg-accent rounded-lg hover:bg-accent-hover transition-all"> <Link href="/pricing" className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-background bg-accent rounded-xl hover:bg-accent-hover shadow-[0_0_20px_rgba(16,185,129,0.15)] transition-all">
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />
Upgrade Upgrade
</Link> </Link>
@ -402,14 +440,14 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Tabs */} {/* Tabs - Landing Page Style */}
<div className="flex items-center gap-1 p-1.5 bg-background-secondary/70 backdrop-blur-sm border border-border rounded-2xl w-fit mb-10"> <div className="flex items-center gap-2 p-2 bg-background-secondary/50 backdrop-blur-sm border border-border rounded-2xl w-fit mb-12">
<button <button
onClick={() => setActiveTab('watchlist')} onClick={() => setActiveTab('watchlist')}
className={clsx( className={clsx(
"flex items-center gap-2.5 px-5 py-2.5 text-ui font-medium rounded-xl transition-all duration-200", "flex items-center gap-2.5 px-6 py-3 text-sm font-medium rounded-xl transition-all duration-300",
activeTab === 'watchlist' activeTab === 'watchlist'
? "bg-foreground text-background shadow-lg" ? "bg-accent text-background shadow-lg shadow-accent/20"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5" : "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)} )}
> >
@ -417,29 +455,40 @@ export default function DashboardPage() {
Watchlist Watchlist
{domains.length > 0 && ( {domains.length > 0 && (
<span className={clsx( <span className={clsx(
"text-ui-xs px-2 py-0.5 rounded-md", "text-xs px-2 py-0.5 rounded-lg",
activeTab === 'watchlist' ? "bg-background/20" : "bg-foreground/10" activeTab === 'watchlist' ? "bg-background/20" : "bg-foreground/10"
)}>{domains.length}</span> )}>{domains.length}</span>
)} )}
</button> </button>
<button {hasPortfolio ? (
onClick={() => setActiveTab('portfolio')} <button
className={clsx( onClick={() => setActiveTab('portfolio')}
"flex items-center gap-2.5 px-5 py-2.5 text-ui font-medium rounded-xl transition-all duration-200", className={clsx(
activeTab === 'portfolio' "flex items-center gap-2.5 px-6 py-3 text-sm font-medium rounded-xl transition-all duration-300",
? "bg-foreground text-background shadow-lg" activeTab === 'portfolio'
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5" ? "bg-accent text-background shadow-lg shadow-accent/20"
)} : "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
> )}
<Briefcase className="w-4 h-4" /> >
Portfolio <Briefcase className="w-4 h-4" />
{portfolio.length > 0 && ( Portfolio
<span className={clsx( {portfolio.length > 0 && (
"text-ui-xs px-2 py-0.5 rounded-md", <span className={clsx(
activeTab === 'portfolio' ? "bg-background/20" : "bg-foreground/10" "text-xs px-2 py-0.5 rounded-lg",
)}>{portfolio.length}</span> activeTab === 'portfolio' ? "bg-background/20" : "bg-foreground/10"
)} )}>{portfolio.length}</span>
</button> )}
</button>
) : (
<Link
href="/pricing"
className="flex items-center gap-2.5 px-6 py-3 text-sm font-medium rounded-xl text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-all duration-300"
>
<Briefcase className="w-4 h-4" />
Portfolio
<span className="text-xs px-2 py-0.5 rounded-lg bg-accent/10 text-accent border border-accent/20">Pro</span>
</Link>
)}
</div> </div>
{error && ( {error && (
@ -452,25 +501,51 @@ export default function DashboardPage() {
{/* Watchlist Tab */} {/* Watchlist Tab */}
{activeTab === 'watchlist' && ( {activeTab === 'watchlist' && (
<div className="space-y-6"> <div className="space-y-8">
{/* Stats Row */} {/* Stats Row - Landing Page Style */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4"> <div className={clsx("grid gap-5", hasExpirationTracking ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3")}>
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors"> <div className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl hover:border-accent/30 hover:bg-background-secondary transition-all duration-300">
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Tracked</p> <div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<p className="text-3xl font-display text-foreground">{domains.length}</p> <div className="relative">
<div className="w-10 h-10 bg-foreground/5 border border-border rounded-xl flex items-center justify-center mb-4 group-hover:border-accent/30 group-hover:bg-accent/5 transition-all">
<Globe className="w-5 h-5 text-foreground-muted group-hover:text-accent transition-colors" />
</div>
<p className="text-xs text-foreground-subtle uppercase tracking-wider mb-1">Tracked</p>
<p className="text-4xl font-display text-foreground">{domains.length}</p>
</div>
</div> </div>
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors"> <div className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl hover:border-accent/30 hover:bg-background-secondary transition-all duration-300">
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Available</p> <div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<p className="text-3xl font-display text-foreground">{availableCount}</p> <div className="relative">
<div className="w-10 h-10 bg-accent/10 border border-accent/20 rounded-xl flex items-center justify-center mb-4">
<Check className="w-5 h-5 text-accent" />
</div>
<p className="text-xs text-foreground-subtle uppercase tracking-wider mb-1">Available</p>
<p className="text-4xl font-display text-accent">{availableCount}</p>
</div>
</div> </div>
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors"> <div className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl hover:border-accent/30 hover:bg-background-secondary transition-all duration-300">
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Monitoring</p> <div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<p className="text-3xl font-display text-foreground">{domains.filter(d => d.notify_on_available).length}</p> <div className="relative">
</div> <div className="w-10 h-10 bg-foreground/5 border border-border rounded-xl flex items-center justify-center mb-4 group-hover:border-accent/30 group-hover:bg-accent/5 transition-all">
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors"> <Bell className="w-5 h-5 text-foreground-muted group-hover:text-accent transition-colors" />
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Expiring</p> </div>
<p className="text-3xl font-display text-foreground">{expiringCount}</p> <p className="text-xs text-foreground-subtle uppercase tracking-wider mb-1">Monitoring</p>
<p className="text-4xl font-display text-foreground">{domains.filter(d => d.notify_on_available).length}</p>
</div>
</div> </div>
{hasExpirationTracking && (
<div className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl hover:border-accent/30 hover:bg-background-secondary transition-all duration-300">
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative">
<div className="w-10 h-10 bg-orange-500/10 border border-orange-500/20 rounded-xl flex items-center justify-center mb-4">
<Clock className="w-5 h-5 text-orange-500" />
</div>
<p className="text-xs text-foreground-subtle uppercase tracking-wider mb-1">Expiring</p>
<p className="text-4xl font-display text-orange-500">{expiringCount}</p>
</div>
</div>
)}
</div> </div>
{/* Limit Warning */} {/* Limit Warning */}
@ -525,7 +600,9 @@ export default function DashboardPage() {
<tr className="border-b border-border"> <tr className="border-b border-border">
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Domain</th> <th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Domain</th>
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden sm:table-cell">Status</th> <th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden sm:table-cell">Status</th>
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden lg:table-cell">Expiration</th> {hasExpirationTracking && (
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden lg:table-cell">Expiration</th>
)}
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden md:table-cell">Last Check</th> <th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden md:table-cell">Last Check</th>
<th className="text-right text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Actions</th> <th className="text-right text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Actions</th>
</tr> </tr>
@ -562,16 +639,18 @@ export default function DashboardPage() {
{domain.is_available ? 'Available' : isMonitoring ? 'Monitoring' : 'Registered'} {domain.is_available ? 'Available' : isMonitoring ? 'Monitoring' : 'Registered'}
</span> </span>
</td> </td>
<td className="px-5 py-4 hidden lg:table-cell"> {hasExpirationTracking && (
{exp ? ( <td className="px-5 py-4 hidden lg:table-cell">
<span className={clsx( {exp ? (
"text-body-sm flex items-center gap-1.5", <span className={clsx(
exp.urgent ? "text-warning font-medium" : "text-foreground-subtle" "text-body-sm flex items-center gap-1.5",
)}> exp.urgent ? "text-warning font-medium" : "text-foreground-subtle"
<Calendar className="w-3.5 h-3.5" />{exp.text} )}>
</span> <Calendar className="w-3.5 h-3.5" />{exp.text}
) : <span className="text-foreground-subtle/50">—</span>} </span>
</td> ) : <span className="text-foreground-subtle/50">—</span>}
</td>
)}
<td className="px-5 py-4 hidden md:table-cell"> <td className="px-5 py-4 hidden md:table-cell">
<span className="text-body-sm text-foreground-subtle flex items-center gap-1.5"> <span className="text-body-sm text-foreground-subtle flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />{formatDate(domain.last_checked)} <Clock className="w-3.5 h-3.5" />{formatDate(domain.last_checked)}
@ -579,14 +658,20 @@ export default function DashboardPage() {
</td> </td>
<td className="px-5 py-4"> <td className="px-5 py-4">
<div className="flex items-center justify-end gap-0.5 opacity-50 group-hover:opacity-100 transition-opacity"> <div className="flex items-center justify-end gap-0.5 opacity-50 group-hover:opacity-100 transition-opacity">
{isProOrHigher && ( {hasHistory && (
<button onClick={() => loadDomainHistory(domain.id)} className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/10 rounded-lg transition-all" title="History"> <button onClick={() => loadDomainHistory(domain.id)} className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/10 rounded-lg transition-all" title="History">
<History className="w-4 h-4" /> <History className="w-4 h-4" />
</button> </button>
)} )}
<button onClick={() => handleGetValuation(domain.name)} className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/10 rounded-lg transition-all" title="Valuation"> {hasDomainValuation ? (
<Sparkles className="w-4 h-4" /> <button onClick={() => handleGetValuation(domain.name)} className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/10 rounded-lg transition-all" title="Valuation">
</button> <Sparkles className="w-4 h-4" />
</button>
) : (
<Link href="/pricing" className="p-2 text-foreground-subtle/50 hover:text-accent hover:bg-accent/10 rounded-lg transition-all" title="Upgrade for Valuation">
<Sparkles className="w-4 h-4" />
</Link>
)}
<button <button
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)} onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
disabled={togglingNotifyId === domain.id} disabled={togglingNotifyId === domain.id}
@ -994,6 +1079,27 @@ export default function DashboardPage() {
)} )}
<Footer /> <Footer />
{/* Toast Notification */}
{toast && (
<Toast
message={toast.message}
type={toast.type}
onClose={hideToast}
/>
)}
</div> </div>
) )
} }
export default function DashboardPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="w-6 h-6 animate-spin text-accent" />
</div>
}>
<DashboardContent />
</Suspense>
)
}

View File

@ -1,13 +1,14 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { Loader2, ArrowRight, Eye, EyeOff } from 'lucide-react' import { api } from '@/lib/api'
import { Loader2, ArrowRight, Eye, EyeOff, CheckCircle } from 'lucide-react'
// Logo Component - Puma Image // Logo Component
function Logo() { function Logo() {
return ( return (
<Image <Image
@ -20,8 +21,29 @@ function Logo() {
) )
} }
export default function LoginPage() { // OAuth Icons
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
)
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
)
}
function LoginForm() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const { login } = useStore() const { login } = useStore()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@ -29,6 +51,26 @@ export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false })
const [verified, setVerified] = useState(false)
// Get redirect URL from query params
const redirectTo = searchParams.get('redirect') || '/dashboard'
// Check for verified status
useEffect(() => {
if (searchParams.get('verified') === 'true') {
setVerified(true)
}
if (searchParams.get('error')) {
setError(searchParams.get('error') === 'oauth_failed' ? 'OAuth authentication failed. Please try again.' : 'Authentication failed')
}
}, [searchParams])
// Load OAuth providers
useEffect(() => {
api.getOAuthProviders().then(setOauthProviders).catch(() => {})
}, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -37,14 +79,183 @@ export default function LoginPage() {
try { try {
await login(email, password) await login(email, password)
router.push('/dashboard') // Redirect to intended destination or dashboard
} catch (err) { router.push(redirectTo)
setError(err instanceof Error ? err.message : 'Authentication failed') } catch (err: unknown) {
console.error('Login error:', err)
if (err instanceof Error) {
setError(err.message || 'Authentication failed')
} else if (typeof err === 'object' && err !== null) {
if ('detail' in err) {
setError(String((err as { detail: unknown }).detail))
} else if ('message' in err) {
setError(String((err as { message: unknown }).message))
} else {
setError('Authentication failed. Please try again.')
}
} else if (typeof err === 'string') {
setError(err)
} else {
setError('Authentication failed. Please try again.')
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
// Generate register link with redirect preserved
const registerLink = redirectTo !== '/dashboard'
? `/register?redirect=${encodeURIComponent(redirectTo)}`
: '/register'
return (
<div className="relative w-full max-w-sm animate-fade-in">
{/* Logo */}
<Link href="/" className="flex justify-center mb-12 sm:mb-16 hover:opacity-80 transition-opacity duration-300">
<Logo />
</Link>
{/* Header */}
<div className="text-center mb-8 sm:mb-10">
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">
Back to the hunt.
</h1>
<p className="text-body-sm sm:text-body text-foreground-muted">
Sign in to your account
</p>
</div>
{/* Verified Message */}
{verified && (
<div className="mb-6 p-4 bg-accent/10 border border-accent/20 rounded-2xl flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-accent shrink-0" />
<p className="text-sm text-accent">Email verified successfully! You can now sign in.</p>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{error && (
<div className="p-3 sm:p-4 bg-danger-muted border border-danger/20 rounded-2xl">
<p className="text-danger text-body-xs sm:text-body-sm text-center">{error}</p>
</div>
)}
<div className="space-y-2.5 sm:space-y-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
required
autoComplete="email"
className="input-elegant text-body-sm sm:text-body"
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
minLength={8}
autoComplete="current-password"
className="input-elegant text-body-sm sm:text-body pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-foreground-muted hover:text-foreground transition-colors duration-200"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</button>
</div>
</div>
<div className="flex justify-end">
<Link
href="/forgot-password"
className="text-body-xs sm:text-body-sm text-foreground-muted hover:text-accent transition-colors duration-300"
>
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 sm:py-4 bg-foreground text-background text-ui-sm sm:text-ui font-medium rounded-xl
hover:bg-foreground/90 disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-300 flex items-center justify-center gap-2 sm:gap-2.5"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
Continue
<ArrowRight className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
</>
)}
</button>
</form>
{/* OAuth Buttons */}
{(oauthProviders.google_enabled || oauthProviders.github_enabled) && (
<div className="mt-6">
{/* Divider */}
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-4 bg-background text-foreground-muted">or continue with</span>
</div>
</div>
<div className="space-y-3">
{oauthProviders.google_enabled && (
<a
href={api.getGoogleLoginUrl(redirectTo)}
className="w-full py-3 sm:py-3.5 bg-[#24292e] text-white text-sm font-medium rounded-xl
hover:bg-[#2f363d] border border-[#24292e]
transition-all duration-300 flex items-center justify-center gap-3"
>
<GoogleIcon className="w-5 h-5" />
Continue with Google
</a>
)}
{oauthProviders.github_enabled && (
<a
href={api.getGitHubLoginUrl(redirectTo)}
className="w-full py-3 sm:py-3.5 bg-[#24292e] text-white text-sm font-medium rounded-xl
hover:bg-[#2f363d] border border-[#24292e]
transition-all duration-300 flex items-center justify-center gap-3"
>
<GitHubIcon className="w-5 h-5" />
Continue with GitHub
</a>
)}
</div>
</div>
)}
{/* Register Link */}
<p className="mt-8 sm:mt-10 text-center text-body-xs sm:text-body-sm text-foreground-muted">
Don&apos;t have an account?{' '}
<Link href={registerLink} className="text-foreground hover:text-accent transition-colors duration-300">
Create one
</Link>
</p>
</div>
)
}
export default function LoginPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12 relative"> <div className="min-h-screen flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12 relative">
{/* Ambient glow */} {/* Ambient glow */}
@ -52,97 +263,11 @@ export default function LoginPage() {
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" /> <div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" />
</div> </div>
<div className="relative w-full max-w-sm animate-fade-in"> <Suspense fallback={
{/* Logo */} <div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<Link href="/" className="flex justify-center mb-12 sm:mb-16 hover:opacity-80 transition-opacity duration-300"> }>
<Logo /> <LoginForm />
</Link> </Suspense>
{/* Header */}
<div className="text-center mb-8 sm:mb-10">
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">Back to the hunt.</h1>
<p className="text-body-sm sm:text-body text-foreground-muted">
Sign in to your account
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
{error && (
<div className="p-3 sm:p-4 bg-danger-muted border border-danger/20 rounded-2xl">
<p className="text-danger text-body-xs sm:text-body-sm text-center">{error}</p>
</div>
)}
<div className="space-y-2.5 sm:space-y-3">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
required
className="input-elegant text-body-sm sm:text-body"
/>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
minLength={8}
className="input-elegant text-body-sm sm:text-body pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 text-foreground-muted hover:text-foreground transition-colors duration-200"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? (
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</button>
</div>
</div>
<div className="flex justify-end">
<Link
href="/forgot-password"
className="text-body-xs sm:text-body-sm text-foreground-muted hover:text-accent transition-colors duration-300"
>
Forgot password?
</Link>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-3 sm:py-4 bg-foreground text-background text-ui-sm sm:text-ui font-medium rounded-xl
hover:bg-foreground/90 disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-300 flex items-center justify-center gap-2 sm:gap-2.5"
>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
Continue
<ArrowRight className="w-3.5 sm:w-4 h-3.5 sm:h-4" />
</>
)}
</button>
</form>
{/* Register Link */}
<p className="mt-8 sm:mt-10 text-center text-body-xs sm:text-body-sm text-foreground-muted">
Don&apos;t have an account?{' '}
<Link href="/register" className="text-foreground hover:text-accent transition-colors duration-300">
Create one
</Link>
</p>
</div>
</div> </div>
) )
} }

View File

@ -0,0 +1,67 @@
'use client'
import { useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useStore } from '@/lib/store'
import { Loader2, CheckCircle } from 'lucide-react'
function OAuthCallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { checkAuth } = useStore()
useEffect(() => {
const token = searchParams.get('token')
const redirect = searchParams.get('redirect') || '/dashboard'
const isNew = searchParams.get('new') === 'true'
const error = searchParams.get('error')
if (error) {
router.push(`/login?error=${error}`)
return
}
if (token) {
// Store the token
localStorage.setItem('auth_token', token)
// Update auth state
checkAuth().then(() => {
// Redirect with welcome message for new users
if (isNew) {
router.push(`${redirect}?welcome=true`)
} else {
router.push(redirect)
}
})
} else {
router.push('/login?error=no_token')
}
}, [searchParams, router, checkAuth])
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<div className="text-center">
<div className="relative mb-6">
<Loader2 className="w-12 h-12 text-accent animate-spin mx-auto" />
<div className="absolute inset-0 bg-accent/20 blur-xl rounded-full" />
</div>
<h2 className="text-xl font-display text-foreground mb-2">Signing you in...</h2>
<p className="text-sm text-foreground-muted">Please wait while we complete authentication</p>
</div>
</div>
)
}
export default function OAuthCallbackPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="w-6 h-6 animate-spin text-accent" />
</div>
}>
<OAuthCallbackContent />
</Suspense>
)
}

View File

@ -7,85 +7,36 @@ import { Footer } from '@/components/Footer'
import { DomainChecker } from '@/components/DomainChecker' import { DomainChecker } from '@/components/DomainChecker'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Eye, Bell, Clock, Shield, ArrowRight, Check, TrendingUp, TrendingDown, Minus, Lock, ChevronRight } from 'lucide-react' import {
Eye,
Bell,
Clock,
Shield,
ArrowRight,
TrendingUp,
TrendingDown,
Minus,
ChevronRight,
Zap,
BarChart3,
Globe,
Check,
} from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
const features = [
{
icon: Eye,
title: 'Always Watching',
description: 'Daily scans. 886+ TLDs. You sleep, we hunt.',
},
{
icon: Bell,
title: 'Instant Alerts',
description: 'Domain drops? You know first. Always.',
},
{
icon: Clock,
title: 'Expiry Intel',
description: 'See when domains expire. Plan your move.',
},
{
icon: Shield,
title: 'Your Strategy, Private',
description: 'No one sees your watchlist. Ever.',
},
]
const tiers = [
{
name: 'Scout',
price: '0',
period: '',
description: 'Test the waters. Zero risk.',
features: ['5 domains', 'Daily checks', 'Email alerts', 'Basic search'],
cta: 'Hunt Free',
highlighted: false,
},
{
name: 'Trader',
price: '19',
period: '/mo',
description: 'Hunt with precision.',
features: ['50 domains', 'Hourly checks', 'SMS alerts', 'Domain valuation', 'Portfolio tracking'],
cta: 'Start Trading',
highlighted: true,
},
{
name: 'Tycoon',
price: '49',
period: '/mo',
description: 'Dominate the market.',
features: ['500 domains', 'Real-time checks', 'API access', 'SEO metrics', 'Bulk tools'],
cta: 'Go Tycoon',
highlighted: false,
},
]
interface TldData {
tld: string
type: string
description: string
avg_registration_price: number
min_registration_price: number
max_registration_price: number
trend: string
}
interface TrendingTld { interface TrendingTld {
tld: string tld: string
reason: string reason: string
current_price: number current_price: number
price_change: number // API returns price_change, not price_change_percent price_change: number
} }
// Shimmer component for locked content // Shimmer for loading states
function ShimmerBlock({ className }: { className?: string }) { function Shimmer({ className }: { className?: string }) {
return ( return (
<div className={clsx( <div className={clsx(
"relative overflow-hidden rounded bg-background-tertiary", "relative overflow-hidden rounded bg-foreground/5",
className className
)}> )}>
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-foreground/5 to-transparent" /> <div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-foreground/5 to-transparent" />
@ -93,9 +44,34 @@ function ShimmerBlock({ className }: { className?: string }) {
) )
} }
// Animated counter
function AnimatedNumber({ value, suffix = '' }: { value: number, suffix?: string }) {
const [count, setCount] = useState(0)
useEffect(() => {
const duration = 2000
const steps = 60
const increment = value / steps
let current = 0
const timer = setInterval(() => {
current += increment
if (current >= value) {
setCount(value)
clearInterval(timer)
} else {
setCount(Math.floor(current))
}
}, duration / steps)
return () => clearInterval(timer)
}, [value])
return <>{count.toLocaleString()}{suffix}</>
}
export default function HomePage() { export default function HomePage() {
const { checkAuth, isLoading, isAuthenticated } = useStore() const { checkAuth, isLoading, isAuthenticated } = useStore()
const [tldData, setTldData] = useState<TldData[]>([])
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([]) const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
const [loadingTlds, setLoadingTlds] = useState(true) const [loadingTlds, setLoadingTlds] = useState(true)
@ -106,11 +82,7 @@ export default function HomePage() {
const fetchTldData = async () => { const fetchTldData = async () => {
try { try {
const [overview, trending] = await Promise.all([ const trending = await api.getTrendingTlds()
api.getTldOverview(8),
api.getTrendingTlds()
])
setTldData(overview.tlds)
setTrendingTlds(trending.trending.slice(0, 4)) setTrendingTlds(trending.trending.slice(0, 4))
} catch (error) { } catch (error) {
console.error('Failed to fetch TLD data:', error) console.error('Failed to fetch TLD data:', error)
@ -121,8 +93,8 @@ export default function HomePage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" /> <div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div> </div>
) )
} }
@ -133,292 +105,338 @@ export default function HomePage() {
return <Minus className="w-3.5 h-3.5" /> return <Minus className="w-3.5 h-3.5" />
} }
const getTrendDirection = (priceChange: number) => {
if (priceChange > 0) return 'up'
if (priceChange < 0) return 'down'
return 'stable'
}
return ( return (
<div className="min-h-screen relative"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Ambient background glow */} {/* Background Effects */}
<div className="fixed inset-0 pointer-events-none"> <div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-accent/[0.03] rounded-full blur-3xl" /> {/* Primary glow */}
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
{/* Secondary glow */}
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
{/* Grid pattern */}
<div
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div> </div>
<Header /> <Header />
{/* Hero Section */} {/* Hero Section */}
<section className="relative pt-28 sm:pt-32 md:pt-36 lg:pt-40 pb-16 sm:pb-20 px-4 sm:px-6"> <section className="relative pt-32 sm:pt-40 md:pt-48 lg:pt-56 pb-20 sm:pb-28 px-4 sm:px-6">
<div className="max-w-7xl mx-auto text-center"> <div className="max-w-7xl mx-auto">
{/* Puma Logo - Compact & Elegant */} <div className="text-center max-w-5xl mx-auto">
<div className="flex justify-center mb-6 sm:mb-8 animate-fade-in"> {/* Puma Logo */}
<Image <div className="flex justify-center mb-8 sm:mb-10 animate-fade-in">
src="/pounce-puma.png" <div className="relative">
alt="pounce" <Image
width={300} src="/pounce-puma.png"
height={210} alt="pounce"
className="w-32 h-auto sm:w-40 md:w-48 object-contain drop-shadow-[0_0_40px_rgba(16,185,129,0.25)]" width={400}
priority height={280}
/> className="w-40 h-auto sm:w-52 md:w-64 object-contain drop-shadow-[0_0_60px_rgba(16,185,129,0.3)]"
</div> priority
/>
{/* Glow ring */}
<div className="absolute inset-0 -z-10 bg-accent/20 blur-3xl rounded-full scale-150" />
</div>
</div>
{/* Main Headline - RESPONSIVE */} {/* Main Headline - MASSIVE */}
<h1 className="font-display text-[2.25rem] leading-[1.1] sm:text-[3rem] md:text-[3.75rem] lg:text-[4.5rem] xl:text-[5.25rem] tracking-[-0.035em] mb-6 sm:mb-8 md:mb-10 animate-slide-up"> <h1 className="animate-slide-up">
<span className="block text-foreground">Others wait.</span> <span className="block font-display text-[3rem] sm:text-[4rem] md:text-[5.5rem] lg:text-[7rem] xl:text-[8rem] leading-[0.9] tracking-[-0.04em] text-foreground">
<span className="block text-foreground-muted">You pounce.</span> Others wait.
</h1> </span>
<span className="block font-display text-[3rem] sm:text-[4rem] md:text-[5.5rem] lg:text-[7rem] xl:text-[8rem] leading-[0.9] tracking-[-0.04em] text-foreground/40 mt-2">
You pounce.
</span>
</h1>
{/* Subheadline - RESPONSIVE */} {/* Subheadline */}
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted max-w-xl sm:max-w-2xl mx-auto mb-10 sm:mb-12 md:mb-16 animate-slide-up delay-100 px-4 sm:px-0"> <p className="mt-8 sm:mt-10 md:mt-12 text-lg sm:text-xl md:text-2xl text-foreground-muted max-w-2xl mx-auto animate-slide-up delay-100 leading-relaxed">
Domain intelligence for the decisive. Track any domain. Domain intelligence for the decisive. Track any domain.
Know the moment it drops. Move before anyone else. Know the moment it drops. Move before anyone else.
</p> </p>
{/* Domain Checker */} {/* Domain Checker */}
<div className="animate-slide-up delay-150"> <div className="mt-10 sm:mt-14 md:mt-16 animate-slide-up delay-200">
<DomainChecker /> <DomainChecker />
</div>
{/* Trust Indicators */}
<div className="mt-12 sm:mt-16 flex flex-wrap items-center justify-center gap-8 sm:gap-12 text-foreground-subtle animate-fade-in delay-300">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-accent" />
<span className="text-sm font-medium"><AnimatedNumber value={886} />+ TLDs tracked</span>
</div>
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-accent" />
<span className="text-sm font-medium">Real-time pricing</span>
</div>
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-accent" />
<span className="text-sm font-medium">Instant alerts</span>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
{/* TLD Price Intelligence Section */} {/* Trending TLDs Section */}
<section className="relative py-16 sm:py-20 md:py-24 px-4 sm:px-6 border-t border-border-subtle"> <section className="relative py-20 sm:py-28 px-4 sm:px-6">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Section Header */} {/* Section Header */}
<div className="text-center mb-10 sm:mb-12"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-10 sm:mb-14">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-accent-muted border border-accent/20 rounded-full mb-5"> <div>
<TrendingUp className="w-4 h-4 text-accent" /> <div className="inline-flex items-center gap-2 px-4 py-2 bg-accent/10 border border-accent/20 rounded-full mb-5">
<span className="text-ui-sm text-accent">Market Intel</span> <TrendingUp className="w-4 h-4 text-accent" />
<span className="text-sm font-medium text-accent">Market Intel</span>
</div>
<h2 className="font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
Trending Now
</h2>
</div> </div>
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6"> <Link
886 TLDs. Tracked Daily. href="/tld-pricing"
</h2> className="group inline-flex items-center gap-2 text-sm font-medium text-accent hover:text-accent-hover transition-colors"
<p className="text-body-sm sm:text-body text-foreground-muted max-w-lg mx-auto"> >
See price movements. Spot opportunities. Act fast. Explore all TLDs
</p> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link>
</div> </div>
{/* Trending TLDs - Card Grid */} {/* TLD Cards */}
<div className="mb-8"> {loadingTlds ? (
<div className="flex items-center gap-2 mb-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<TrendingUp className="w-4 h-4 text-accent" /> {[...Array(4)].map((_, i) => (
<span className="text-ui font-medium text-foreground">Trending Now</span> <div key={i} className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
<Shimmer className="h-8 w-20 mb-4" />
<Shimmer className="h-4 w-full mb-2" />
<Shimmer className="h-4 w-24" />
</div>
))}
</div> </div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
{trendingTlds.map((item, index) => (
<Link
key={item.tld}
href={isAuthenticated ? `/tld-pricing/${item.tld}` : `/login?redirect=/tld-pricing/${item.tld}`}
className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl
hover:border-accent/30 hover:bg-background-secondary transition-all duration-300"
style={{ animationDelay: `${index * 100}ms` }}
>
{/* Hover glow */}
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
{loadingTlds ? ( <div className="relative">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="flex items-center justify-between mb-4">
{[...Array(4)].map((_, i) => ( <span className="font-mono text-2xl sm:text-3xl font-medium text-foreground">.{item.tld}</span>
<div key={i} className="p-5 bg-background-secondary border border-border rounded-xl">
<ShimmerBlock className="h-6 w-16 mb-3" />
<ShimmerBlock className="h-4 w-full mb-2" />
<ShimmerBlock className="h-4 w-20" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{trendingTlds.map((item) => (
<Link
key={item.tld}
href={isAuthenticated ? `/tld-pricing/${item.tld}` : '/register'}
className="group p-5 bg-background-secondary border border-border rounded-xl
hover:border-border-hover transition-all duration-300"
>
<div className="flex items-center justify-between mb-3">
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span>
<span className={clsx( <span className={clsx(
"flex items-center gap-1 text-ui-sm font-medium px-2 py-0.5 rounded-full", "flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-full",
(item.price_change ?? 0) > 0 (item.price_change ?? 0) > 0
? "text-[#f97316] bg-[#f9731615]" ? "text-orange-400 bg-orange-400/10"
: (item.price_change ?? 0) < 0 : (item.price_change ?? 0) < 0
? "text-accent bg-accent-muted" ? "text-accent bg-accent/10"
: "text-foreground-muted bg-background-tertiary" : "text-foreground-muted bg-foreground/5"
)}> )}>
{getTrendIcon(item.price_change ?? 0)} {getTrendIcon(item.price_change ?? 0)}
{(item.price_change ?? 0) > 0 ? '+' : ''}{(item.price_change ?? 0).toFixed(1)}% {(item.price_change ?? 0) > 0 ? '+' : ''}{(item.price_change ?? 0).toFixed(1)}%
</span> </span>
</div> </div>
<p className="text-body-xs text-foreground-subtle mb-3 line-clamp-2">{item.reason}</p> <p className="text-sm text-foreground-subtle mb-4 line-clamp-2 min-h-[40px]">{item.reason}</p>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{isAuthenticated ? ( {isAuthenticated ? (
<span className="text-body-sm text-foreground">${(item.current_price ?? 0).toFixed(2)}/yr</span> <span className="text-lg font-semibold text-foreground">${(item.current_price ?? 0).toFixed(2)}<span className="text-sm font-normal text-foreground-muted">/yr</span></span>
) : ( ) : (
<ShimmerBlock className="h-5 w-20" /> <Shimmer className="h-6 w-20" />
)} )}
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" /> <ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent group-hover:translate-x-1 transition-all" />
</div> </div>
</Link> </div>
))} </Link>
</div> ))}
)}
</div>
{/* Login CTA for non-authenticated users */}
{!isAuthenticated && (
<div className="p-5 bg-background-secondary/50 border border-border rounded-xl flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-accent-muted rounded-xl flex items-center justify-center">
<Lock className="w-4 h-4 text-accent" />
</div>
<div>
<p className="text-body-sm font-medium text-foreground">Unlock Full Data</p>
<p className="text-ui-sm text-foreground-subtle">
Sign in for prices, trends, and registrar comparisons.
</p>
</div>
</div>
<Link
href="/register"
className="shrink-0 px-5 py-2.5 bg-accent text-background text-ui font-medium rounded-lg
hover:bg-accent-hover transition-all duration-300"
>
Get Started Free
</Link>
</div> </div>
)} )}
</div>
</section>
{/* View All Link */} {/* Features Section */}
<div className="mt-6 text-center"> <section className="relative py-20 sm:py-28 px-4 sm:px-6">
<div className="max-w-7xl mx-auto">
{/* Section Header */}
<div className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">How It Works</span>
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl tracking-[-0.03em] text-foreground">
Built for hunters.
</h2>
<p className="mt-5 text-lg text-foreground-muted">
The tools that give you the edge. Simple. Powerful. Decisive.
</p>
</div>
{/* Feature Cards */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[
{
icon: Eye,
title: 'Always Watching',
description: 'Daily scans across 886+ TLDs. You sleep, we hunt.',
},
{
icon: Bell,
title: 'Instant Alerts',
description: 'Domain drops? You know first. Email alerts the moment it happens.',
},
{
icon: Clock,
title: 'Expiry Intel',
description: 'See when domains expire. Plan your acquisition strategy.',
},
{
icon: Shield,
title: 'Your Strategy, Private',
description: 'Your watchlist is yours alone. No one sees what you\'re tracking.',
},
].map((feature, i) => (
<div
key={feature.title}
className="group relative p-8 rounded-2xl border border-transparent hover:border-border
bg-transparent hover:bg-background-secondary/50 transition-all duration-500"
>
<div className="w-14 h-14 bg-foreground/5 border border-border rounded-2xl flex items-center justify-center mb-6
group-hover:border-accent/30 group-hover:bg-accent/5 transition-all duration-500">
<feature.icon className="w-6 h-6 text-foreground-muted group-hover:text-accent transition-colors duration-500" strokeWidth={1.5} />
</div>
<h3 className="text-lg font-semibold text-foreground mb-3">{feature.title}</h3>
<p className="text-sm text-foreground-subtle leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Social Proof / Stats Section */}
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
<div className="max-w-5xl mx-auto">
<div className="relative p-10 sm:p-14 md:p-20 bg-gradient-to-br from-background-secondary/80 to-background-secondary/40
border border-border rounded-3xl overflow-hidden">
{/* Background pattern */}
<div className="absolute inset-0 opacity-30">
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-accent/10 rounded-full blur-[100px]" />
</div>
<div className="relative grid sm:grid-cols-3 gap-10 sm:gap-6 text-center">
<div>
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
<AnimatedNumber value={886} />+
</p>
<p className="text-sm text-foreground-muted">TLDs Tracked</p>
</div>
<div>
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
24<span className="text-accent">/</span>7
</p>
<p className="text-sm text-foreground-muted">Monitoring</p>
</div>
<div>
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
<AnimatedNumber value={10} />s
</p>
<p className="text-sm text-foreground-muted">Alert Speed</p>
</div>
</div>
</div>
</div>
</section>
{/* Pricing CTA Section */}
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
<div className="max-w-4xl mx-auto text-center">
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Pricing</span>
<h2 className="mt-4 font-display text-3xl sm:text-4xl md:text-5xl lg:text-6xl tracking-[-0.03em] text-foreground">
Pick your weapon.
</h2>
<p className="mt-5 text-lg text-foreground-muted max-w-xl mx-auto">
Start free with 5 domains. Scale to 500+ when you need more firepower.
</p>
{/* Quick Plans */}
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4">
<div className="flex items-center gap-4 px-6 py-4 bg-background-secondary/50 border border-border rounded-2xl">
<div className="w-12 h-12 bg-foreground/5 rounded-xl flex items-center justify-center">
<Zap className="w-5 h-5 text-foreground-muted" />
</div>
<div className="text-left">
<p className="font-semibold text-foreground">Scout</p>
<p className="text-sm text-foreground-muted">Free forever</p>
</div>
</div>
<ArrowRight className="w-5 h-5 text-foreground-subtle hidden sm:block" />
<ChevronRight className="w-5 h-5 text-foreground-subtle rotate-90 sm:hidden" />
<div className="flex items-center gap-4 px-6 py-4 bg-accent/5 border border-accent/20 rounded-2xl">
<div className="w-12 h-12 bg-accent/10 rounded-xl flex items-center justify-center">
<TrendingUp className="w-5 h-5 text-accent" />
</div>
<div className="text-left">
<p className="font-semibold text-foreground">Trader</p>
<p className="text-sm text-accent">$19/month</p>
</div>
</div>
</div>
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
<Link <Link
href="/tld-pricing" href="/pricing"
className="inline-flex items-center gap-2 text-body-sm font-medium text-accent hover:text-accent-hover transition-colors" className="inline-flex items-center gap-2 px-8 py-4 bg-foreground text-background rounded-xl
font-semibold hover:bg-foreground/90 transition-all duration-300"
> >
Explore All TLDs Compare Plans
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Link> </Link>
<Link
href={isAuthenticated ? "/dashboard" : "/register"}
className="inline-flex items-center gap-2 px-8 py-4 text-foreground-muted hover:text-foreground transition-colors"
>
{isAuthenticated ? "Go to Dashboard" : "Start Free"}
<ChevronRight className="w-4 h-4" />
</Link>
</div> </div>
</div> </div>
</section> </section>
{/* Features Section */} {/* Final CTA */}
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6"> <section className="relative py-24 sm:py-32 px-4 sm:px-6">
<div className="max-w-7xl mx-auto"> <div className="max-w-4xl mx-auto text-center">
<div className="text-center mb-12 sm:mb-16 md:mb-20"> <h2 className="font-display text-4xl sm:text-5xl md:text-6xl lg:text-7xl tracking-[-0.03em] text-foreground mb-6">
<p className="label sm:label-md text-accent mb-3 sm:mb-4 md:mb-5">How It Works</p> Ready to hunt?
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
Built for hunters.
</h2>
<p className="text-body-sm sm:text-body md:text-body-lg text-foreground-muted max-w-md sm:max-w-lg mx-auto px-4 sm:px-0">
The tools that give you the edge. Simple. Powerful. Decisive.
</p>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
{features.map((feature, i) => (
<div
key={feature.title}
className="group p-5 sm:p-6 rounded-2xl border border-transparent hover:border-border hover:bg-background-secondary/50 transition-all duration-500"
style={{ animationDelay: `${i * 100}ms` }}
>
<div className="w-10 sm:w-11 h-10 sm:h-11 bg-background-secondary border border-border rounded-xl flex items-center justify-center mb-4 sm:mb-5
group-hover:border-accent/30 group-hover:bg-accent/5 transition-all duration-500">
<feature.icon className="w-4 sm:w-5 h-4 sm:h-5 text-foreground-muted group-hover:text-accent transition-colors duration-500" strokeWidth={1.5} />
</div>
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-2">{feature.title}</h3>
<p className="text-body-xs sm:text-body-sm text-foreground-subtle leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* Pricing Section */}
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
{/* Section glow */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
</div>
<div className="relative max-w-5xl mx-auto">
<div className="text-center mb-12 sm:mb-16 md:mb-20">
<p className="label sm:label-md text-accent mb-3 sm:mb-4 md:mb-5">Pricing</p>
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
Pick your weapon.
</h2>
<p className="text-body-sm sm:text-body md:text-body-lg text-foreground-muted">
Start free. Scale when you&apos;re ready.
</p>
</div>
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-5">
{tiers.map((tier, i) => (
<div
key={tier.name}
className={`relative p-5 sm:p-6 md:p-7 rounded-2xl border transition-all duration-500 ${
tier.highlighted
? 'bg-background-secondary border-accent/20 glow-accent'
: 'bg-background-secondary/50 border-border hover:border-border-hover'
}`}
style={{ animationDelay: `${i * 100}ms` }}
>
{tier.highlighted && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="px-3 py-1 bg-accent text-background text-ui-xs sm:text-ui-sm font-medium rounded-full">
Popular
</span>
</div>
)}
<div className="mb-5 sm:mb-6">
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-1">{tier.name}</h3>
<p className="text-ui-sm sm:text-ui text-foreground-subtle mb-4 sm:mb-5">{tier.description}</p>
<div className="flex items-baseline gap-1">
{tier.price === '0' ? (
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">Free</span>
) : (
<>
<span className="text-heading-md sm:text-heading-lg font-display text-foreground">${tier.price}</span>
<span className="text-body-sm text-foreground-subtle">{tier.period}</span>
</>
)}
</div>
</div>
<ul className="space-y-2.5 sm:space-y-3 mb-6 sm:mb-8">
{tier.features.map((feature) => (
<li key={feature} className="flex items-center gap-2.5 sm:gap-3 text-body-xs sm:text-body-sm">
<Check className="w-3.5 sm:w-4 h-3.5 sm:h-4 text-accent shrink-0" strokeWidth={2.5} />
<span className="text-foreground-muted">{feature}</span>
</li>
))}
</ul>
<Link
href={isAuthenticated ? '/dashboard' : '/register'}
className={`w-full flex items-center justify-center gap-2 py-2.5 sm:py-3 rounded-xl text-ui-sm sm:text-ui font-medium transition-all duration-300 ${
tier.highlighted
? 'bg-accent text-background hover:bg-accent-hover'
: 'bg-background-tertiary text-foreground border border-border hover:border-border-hover'
}`}
>
{tier.cta}
</Link>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
<div className="max-w-2xl mx-auto text-center">
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
Start monitoring today
</h2> </h2>
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted mb-8 sm:mb-10"> <p className="text-xl text-foreground-muted mb-10 max-w-lg mx-auto">
Create a free account and track up to 3 domains. Track your first domain in under a minute. No credit card required.
No credit card required.
</p> </p>
<Link <Link
href="/register" href={isAuthenticated ? "/dashboard" : "/register"}
className="btn-primary inline-flex items-center gap-2 sm:gap-2.5 px-6 sm:px-8 py-3 sm:py-4 text-body-sm sm:text-body" className="group inline-flex items-center gap-3 px-10 py-5 bg-accent text-background rounded-2xl
text-lg font-semibold hover:bg-accent-hover transition-all duration-300
shadow-[0_0_40px_rgba(16,185,129,0.2)] hover:shadow-[0_0_60px_rgba(16,185,129,0.3)]"
> >
Get Started Free {isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
{!isAuthenticated && (
<p className="mt-6 text-sm text-foreground-subtle">
<Check className="w-4 h-4 inline mr-1 text-accent" />
Free forever No credit card 5 domains included
</p>
)}
</div> </div>
</section> </section>

View File

@ -6,7 +6,7 @@ import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Check, ArrowRight, Zap, TrendingUp, Crown, Briefcase, Shield, Bell, Clock, BarChart3, Code, Globe, Loader2 } from 'lucide-react' import { Check, ArrowRight, Zap, TrendingUp, Crown, Loader2, Clock, X } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import clsx from 'clsx' import clsx from 'clsx'
@ -19,11 +19,10 @@ const tiers = [
period: '', period: '',
description: 'Test the waters. Zero risk.', description: 'Test the waters. Zero risk.',
features: [ features: [
{ text: '5 domains to track', highlight: false }, { text: '5 domains to track', highlight: false, available: true },
{ text: 'Daily availability scans', highlight: false }, { text: 'Daily availability scans', highlight: false, available: true },
{ text: 'Email alerts', highlight: false }, { text: 'Email alerts', highlight: false, available: true },
{ text: 'Basic search', highlight: false }, { text: 'TLD price overview', highlight: false, available: true },
{ text: 'TLD price overview', highlight: false },
], ],
cta: 'Hunt Free', cta: 'Hunt Free',
highlighted: false, highlighted: false,
@ -38,13 +37,14 @@ const tiers = [
period: '/mo', period: '/mo',
description: 'Hunt with precision. Daily intel.', description: 'Hunt with precision. Daily intel.',
features: [ features: [
{ text: '50 domains to track', highlight: true }, { text: '50 domains to track', highlight: true, available: true },
{ text: 'Hourly scans', highlight: true }, { text: 'Hourly scans', highlight: true, available: true },
{ text: 'SMS & Telegram alerts', highlight: true }, { text: 'Email alerts', highlight: false, available: true },
{ text: 'Full market data', highlight: false }, { text: 'Full TLD market data', highlight: false, available: true },
{ text: 'Domain valuation', highlight: true }, { text: 'Domain valuation', highlight: true, available: true },
{ text: 'Portfolio (25 domains)', highlight: true }, { text: 'Portfolio (25 domains)', highlight: true, available: true },
{ text: '90-day price history', highlight: false }, { text: '90-day price history', highlight: false, available: true },
{ text: 'Expiry tracking', highlight: true, available: true },
], ],
cta: 'Start Trading', cta: 'Start Trading',
highlighted: true, highlighted: true,
@ -59,14 +59,12 @@ const tiers = [
period: '/mo', period: '/mo',
description: 'Dominate the market. No limits.', description: 'Dominate the market. No limits.',
features: [ features: [
{ text: '500 domains to track', highlight: true }, { text: '500 domains to track', highlight: true, available: true },
{ text: 'Real-time scans (10 min)', highlight: true }, { text: 'Real-time scans (10 min)', highlight: true, available: true },
{ text: 'Priority alerts + Webhooks', highlight: true }, { text: 'Priority email alerts', highlight: false, available: true },
{ text: 'Full REST API', highlight: true }, { text: 'Unlimited portfolio', highlight: true, available: true },
{ text: 'SEO metrics (DA/PA)', highlight: true }, { text: 'Full price history', highlight: true, available: true },
{ text: 'Unlimited portfolio', highlight: true }, { text: 'Advanced valuation', highlight: true, available: true },
{ text: 'Bulk import/export', highlight: true },
{ text: 'White-label reports', highlight: false },
], ],
cta: 'Go Tycoon', cta: 'Go Tycoon',
highlighted: false, highlighted: false,
@ -80,10 +78,8 @@ const comparisonFeatures = [
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' }, { name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' }, { name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
{ name: 'Domain Valuation', scout: '—', trader: '✓', tycoon: '✓' }, { name: 'Domain Valuation', scout: '—', trader: '✓', tycoon: '✓' },
{ name: 'SEO Metrics', scout: '—', trader: '', tycoon: '' }, { name: 'Price History', scout: '—', trader: '90 days', tycoon: 'Unlimited' },
{ name: 'API Access', scout: '—', trader: '', tycoon: '✓' }, { name: 'Expiry Tracking', scout: '—', trader: '', tycoon: '✓' },
{ name: 'Webhooks', scout: '—', trader: '—', tycoon: '✓' },
{ name: 'SMS/Telegram', scout: '—', trader: '✓', tycoon: '✓' },
] ]
const faqs = [ const faqs = [
@ -99,10 +95,6 @@ const faqs = [
q: 'Can I track domains I already own?', q: 'Can I track domains I already own?',
a: 'Absolutely. Trader and Tycoon plans include portfolio tracking. See value changes, renewal dates, and ROI at a glance.', a: 'Absolutely. Trader and Tycoon plans include portfolio tracking. See value changes, renewal dates, and ROI at a glance.',
}, },
{
q: 'How accurate is the SEO data?',
a: 'We integrate with industry-standard APIs to provide Domain Authority, Page Authority, and backlink data. Data is refreshed weekly for accuracy.',
},
{ {
q: 'Can I upgrade or downgrade anytime?', q: 'Can I upgrade or downgrade anytime?',
a: 'Absolutely. You can change your plan at any time. Upgrades take effect immediately, and downgrades apply at the next billing cycle.', a: 'Absolutely. You can change your plan at any time. Upgrades take effect immediately, and downgrades apply at the next billing cycle.',
@ -116,308 +108,236 @@ const faqs = [
export default function PricingPage() { export default function PricingPage() {
const router = useRouter() const router = useRouter()
const { checkAuth, isLoading, isAuthenticated } = useStore() const { checkAuth, isLoading, isAuthenticated } = useStore()
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
const [loadingPlan, setLoadingPlan] = useState<string | null>(null) const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null) const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
checkAuth() checkAuth()
}, [checkAuth]) }, [checkAuth])
const getPrice = (basePrice: string) => { const handleSelectPlan = async (planId: string, isPaid: boolean) => {
if (basePrice === '0') return '0'
const price = parseFloat(basePrice)
if (billingCycle === 'yearly') {
return (price * 10).toFixed(0) // 2 months free
}
return basePrice
}
const handleSelectPlan = async (tier: typeof tiers[0]) => {
setError(null)
// Free plan - go to register or dashboard
if (!tier.isPaid) {
if (isAuthenticated) {
router.push('/dashboard')
} else {
router.push('/register')
}
return
}
// Paid plan - need authentication first
if (!isAuthenticated) { if (!isAuthenticated) {
// Save intended plan and redirect to register router.push(`/register?redirect=/pricing`)
sessionStorage.setItem('intended_plan', tier.id)
router.push('/register')
return return
} }
// Authenticated user - create Stripe checkout if (!isPaid) {
setLoadingPlan(tier.id) router.push('/dashboard')
return
}
setLoadingPlan(planId)
try { try {
const { checkout_url } = await api.createCheckoutSession( const response = await api.createCheckoutSession(
tier.id, planId,
`${window.location.origin}/dashboard?upgraded=true&plan=${tier.id}`, `${window.location.origin}/dashboard?upgraded=true`,
`${window.location.origin}/pricing?cancelled=true` `${window.location.origin}/pricing`
) )
window.location.href = response.checkout_url
// Redirect to Stripe Checkout } catch (error) {
window.location.href = checkout_url console.error('Failed to create checkout:', error)
} catch (err: any) {
console.error('Checkout error:', err)
setError(err.message || 'Failed to start checkout. Please try again.')
setLoadingPlan(null) setLoadingPlan(null)
} }
} }
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
)
}
return ( return (
<div className="min-h-screen bg-background relative flex flex-col"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Ambient glow */} {/* Background Effects - matching landing page */}
<div className="fixed inset-0 pointer-events-none"> <div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" /> <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
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div> </div>
<Header /> <Header />
<main className="relative pt-28 sm:pt-32 md:pt-36 pb-16 sm:pb-20 px-4 sm:px-6 flex-1"> <main className="flex-1 relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Header */} {/* Hero */}
<div className="text-center mb-10 sm:mb-12"> <div className="text-center mb-16 sm:mb-20 animate-fade-in">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6 animate-fade-in"> <span className="text-sm font-semibold text-accent uppercase tracking-wider">Pricing</span>
<Briefcase className="w-4 h-4 text-accent" /> <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">
<span className="text-ui-sm text-foreground-muted">Pricing</span>
</div>
<h1 className="font-display text-[2.25rem] sm:text-[3rem] md:text-[3.75rem] leading-[1.1] tracking-[-0.035em] text-foreground mb-4 animate-slide-up">
Pick your weapon. Pick your weapon.
</h1> </h1>
<p className="text-body-lg text-foreground-muted max-w-2xl mx-auto mb-8 animate-slide-up"> <p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-xl mx-auto">
Casual observer or full-time hunter? We&apos;ve got you covered. Start free. Scale when you&apos;re ready. All plans include core features.
</p> </p>
{/* Error Message */}
{error && (
<div className="max-w-md mx-auto mb-6 p-4 bg-danger/10 border border-danger/20 rounded-xl text-danger text-body-sm">
{error}
</div>
)}
{/* Billing Toggle */}
<div className="inline-flex items-center gap-3 p-1.5 bg-background-secondary border border-border rounded-xl animate-slide-up">
<button
onClick={() => setBillingCycle('monthly')}
className={clsx(
"px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
billingCycle === 'monthly'
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
Monthly
</button>
<button
onClick={() => setBillingCycle('yearly')}
className={clsx(
"px-4 py-2 text-ui-sm font-medium rounded-lg transition-all flex items-center gap-2",
billingCycle === 'yearly'
? "bg-foreground text-background"
: "text-foreground-muted hover:text-foreground"
)}
>
Yearly
<span className="text-ui-xs px-1.5 py-0.5 bg-accent text-background rounded-full">
-17%
</span>
</button>
</div>
</div> </div>
{/* Pricing Cards */} {/* Pricing Cards */}
<div className="grid md:grid-cols-3 gap-4 sm:gap-6 mb-16 sm:mb-20"> <div className="grid md:grid-cols-3 gap-6 mb-20 animate-slide-up">
{tiers.map((tier, i) => ( {tiers.map((tier, index) => (
<div <div
key={tier.name} key={tier.id}
className={clsx( className={clsx(
"relative p-6 sm:p-8 rounded-2xl border transition-all duration-500 animate-slide-up", "group relative p-6 sm:p-8 rounded-2xl border transition-all duration-500",
tier.highlighted tier.highlighted
? 'bg-background-secondary border-accent/30 shadow-[0_0_60px_-20px_rgba(16,185,129,0.3)]' ? "bg-background-secondary border-accent/30 shadow-lg shadow-accent/5"
: 'bg-background-secondary/50 border-border hover:border-border-hover' : "bg-background-secondary/50 border-border hover:border-accent/20 hover:bg-background-secondary"
)} )}
style={{ animationDelay: `${100 + i * 100}ms` }} style={{ animationDelay: `${index * 100}ms` }}
> >
{/* Hover glow for non-highlighted */}
{!tier.highlighted && (
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
)}
{tier.badge && ( {tier.badge && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2"> <div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
<span className={clsx( <span className="px-3 py-1 bg-accent text-background text-ui-xs font-medium rounded-full whitespace-nowrap">
"px-3 py-1 text-ui-xs font-medium rounded-full",
tier.highlighted
? "bg-accent text-background"
: "bg-foreground text-background"
)}>
{tier.badge} {tier.badge}
</span> </span>
</div> </div>
)} )}
{/* Icon & Name */} <div className="relative">
<div className="flex items-center gap-3 mb-4"> {/* Header */}
<div className={clsx( <div className="mb-6">
"w-10 h-10 rounded-xl flex items-center justify-center", <div className="flex items-center gap-3 mb-3">
tier.highlighted ? "bg-accent/20" : "bg-background-tertiary" <div className={clsx(
)}> "w-12 h-12 rounded-2xl flex items-center justify-center border transition-all duration-500",
<tier.icon className={clsx( tier.highlighted
"w-5 h-5", ? "bg-accent/10 border-accent/30"
tier.highlighted ? "text-accent" : "text-foreground-muted" : "bg-foreground/5 border-border group-hover:border-accent/30 group-hover:bg-accent/5"
)} />
</div>
<h3 className="text-body-lg font-medium text-foreground">{tier.name}</h3>
</div>
<p className="text-body-sm text-foreground-muted mb-5">{tier.description}</p>
{/* Price */}
<div className="flex items-baseline gap-1 mb-6">
{tier.price === '0' ? (
<span className="text-[2.5rem] font-display text-foreground">Free</span>
) : (
<>
<span className="text-ui text-foreground-subtle"></span>
<span className="text-[2.5rem] font-display text-foreground leading-none">
{getPrice(tier.price)}
</span>
<span className="text-body-sm text-foreground-subtle">
/{billingCycle === 'yearly' ? 'year' : 'mo'}
</span>
</>
)}
</div>
{/* Features */}
<ul className="space-y-3 mb-8">
{tier.features.map((feature) => (
<li key={feature.text} className="flex items-start gap-3 text-body-sm">
<Check className={clsx(
"w-4 h-4 shrink-0 mt-0.5",
feature.highlight ? "text-accent" : "text-foreground-subtle"
)} strokeWidth={2.5} />
<span className={clsx(
feature.highlight ? "text-foreground" : "text-foreground-muted"
)}> )}>
{feature.text} <tier.icon className={clsx(
</span> "w-5 h-5 transition-colors duration-500",
</li> tier.highlighted ? "text-accent" : "text-foreground-muted group-hover:text-accent"
))} )} />
</ul> </div>
<h3 className="text-xl font-semibold text-foreground">{tier.name}</h3>
</div>
<p className="text-body-sm text-foreground-muted mb-4">{tier.description}</p>
<div className="flex items-baseline gap-1">
{tier.price === '0' ? (
<span className="text-5xl font-display text-foreground">Free</span>
) : (
<>
<span className="text-5xl font-display text-foreground">${tier.price}</span>
<span className="text-body text-foreground-muted">{tier.period}</span>
</>
)}
</div>
</div>
{/* CTA Button */} {/* Features */}
<button <ul className="space-y-3 mb-8">
onClick={() => handleSelectPlan(tier)} {tier.features.map((feature) => (
disabled={loadingPlan === tier.id} <li key={feature.text} className="flex items-start gap-3">
className={clsx( <Check className={clsx(
"w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-ui font-medium transition-all", "w-4 h-4 mt-0.5 shrink-0",
tier.highlighted feature.highlight ? "text-accent" : "text-foreground-muted"
? 'bg-accent text-background hover:bg-accent-hover disabled:bg-accent/50' )} strokeWidth={2.5} />
: 'bg-foreground text-background hover:bg-foreground/90 disabled:bg-foreground/50', <span className={clsx(
loadingPlan === tier.id && 'cursor-not-allowed' "text-body-sm",
)} feature.highlight ? "text-foreground" : "text-foreground-muted"
> )}>
{loadingPlan === tier.id ? ( {feature.text}
<Loader2 className="w-4 h-4 animate-spin" /> </span>
) : ( </li>
<> ))}
{tier.cta} </ul>
<ArrowRight className="w-4 h-4" />
</> {/* CTA */}
)} <button
</button> onClick={() => handleSelectPlan(tier.id, tier.isPaid)}
disabled={loadingPlan === tier.id}
className={clsx(
"w-full flex items-center justify-center gap-2 py-4 rounded-xl text-ui font-medium transition-all duration-300",
tier.highlighted
? "bg-accent text-background hover:bg-accent-hover shadow-[0_0_20px_rgba(16,185,129,0.15)]"
: "bg-foreground text-background hover:bg-foreground/90"
)}
>
{loadingPlan === tier.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
{tier.cta}
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div> </div>
))} ))}
</div> </div>
{/* Feature Comparison Table */} {/* Comparison Table */}
<div className="mb-16 sm:mb-20"> <div className="mb-20">
<h2 className="text-heading-md font-medium text-foreground text-center mb-8"> <h2 className="text-heading-md text-foreground text-center mb-8">Compare Plans</h2>
Compare Plans <div className="overflow-x-auto">
</h2> <table className="w-full">
<div className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden"> <thead>
<div className="overflow-x-auto"> <tr className="border-b border-border">
<table className="w-full"> <th className="text-left py-4 px-4 text-body-sm font-medium text-foreground-muted">Feature</th>
<thead> <th className="text-center py-4 px-4 text-body-sm font-medium text-foreground-muted">Scout</th>
<tr className="border-b border-border"> <th className="text-center py-4 px-4 text-body-sm font-medium text-accent">Trader</th>
<th className="text-left text-ui-sm text-foreground-subtle font-medium px-6 py-4">Feature</th> <th className="text-center py-4 px-4 text-body-sm font-medium text-foreground-muted">Tycoon</th>
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Scout</th> </tr>
<th className="text-center text-ui-sm text-accent font-medium px-4 py-4 bg-accent/5">Trader</th> </thead>
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Tycoon</th> <tbody>
{comparisonFeatures.map((feature) => (
<tr key={feature.name} className="border-b border-border/50">
<td className="py-4 px-4 text-body-sm text-foreground">{feature.name}</td>
<td className="py-4 px-4 text-center text-body-sm text-foreground-muted">{feature.scout}</td>
<td className="py-4 px-4 text-center text-body-sm text-foreground">{feature.trader}</td>
<td className="py-4 px-4 text-center text-body-sm text-foreground">{feature.tycoon}</td>
</tr> </tr>
</thead> ))}
<tbody className="divide-y divide-border"> </tbody>
{comparisonFeatures.map((feature) => ( </table>
<tr key={feature.name}>
<td className="text-body-sm text-foreground px-6 py-4">{feature.name}</td>
<td className="text-body-sm text-foreground-muted text-center px-4 py-4">{feature.scout}</td>
<td className="text-body-sm text-foreground text-center px-4 py-4 bg-accent/5 font-medium">{feature.trader}</td>
<td className="text-body-sm text-foreground-muted text-center px-4 py-4">{feature.tycoon}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
{/* Trust Badges */} {/* FAQ */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-16 sm:mb-20">
{[
{ icon: Shield, text: '256-bit SSL' },
{ icon: Bell, text: 'Instant Alerts' },
{ icon: Clock, text: '99.9% Uptime' },
{ icon: Globe, text: '500+ TLDs' },
].map((item) => (
<div key={item.text} className="flex items-center justify-center gap-2 p-4 bg-background-secondary/30 border border-border rounded-xl">
<item.icon className="w-4 h-4 text-accent" />
<span className="text-ui-sm text-foreground-muted">{item.text}</span>
</div>
))}
</div>
{/* FAQ Section */}
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<h2 className="text-heading-md font-medium text-foreground text-center mb-8"> <h2 className="text-heading-md text-foreground text-center mb-8">Frequently Asked</h2>
Frequently Asked Questions
</h2>
<div className="space-y-3"> <div className="space-y-3">
{faqs.map((faq, i) => ( {faqs.map((faq, i) => (
<div <div
key={i} key={i}
className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all" className="border border-border rounded-xl overflow-hidden"
> >
<h3 className="text-body font-medium text-foreground mb-2">{faq.q}</h3> <button
<p className="text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p> onClick={() => setExpandedFaq(expandedFaq === i ? null : i)}
className="w-full flex items-center justify-between p-5 text-left hover:bg-foreground/5 transition-colors"
>
<span className="text-body font-medium text-foreground pr-4">{faq.q}</span>
<span className={clsx(
"shrink-0 w-6 h-6 flex items-center justify-center rounded-lg transition-all",
expandedFaq === i ? "bg-accent/10 text-accent rotate-45" : "bg-foreground/5 text-foreground-muted"
)}>
<span className="text-lg leading-none">+</span>
</span>
</button>
{expandedFaq === i && (
<div className="px-5 pb-5">
<p className="text-body-sm text-foreground-muted">{faq.a}</p>
</div>
)}
</div> </div>
))} ))}
</div> </div>
</div> </div>
{/* CTA */} {/* Bottom CTA */}
<div className="mt-16 sm:mt-20 text-center"> <div className="text-center mt-20 py-12 px-6 bg-background-secondary/50 border border-border rounded-2xl">
<h2 className="text-heading-md text-foreground mb-3">Not sure yet?</h2>
<p className="text-body text-foreground-muted mb-6"> <p className="text-body text-foreground-muted mb-6">
Not sure which plan is right for you? Start with Scout. It&apos;s free forever. Upgrade when you need more.
</p> </p>
<Link <Link
href="/contact" href={isAuthenticated ? "/dashboard" : "/register"}
className="inline-flex items-center gap-2 px-6 py-3 bg-background-secondary border border-border text-foreground font-medium rounded-xl hover:border-border-hover transition-all" className="btn-primary inline-flex items-center gap-2 px-6 py-3"
> >
Contact Sales {isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />
</Link> </Link>
</div> </div>

View File

@ -1,13 +1,14 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { Loader2, ArrowRight, Check, Eye, EyeOff } from 'lucide-react' import { api } from '@/lib/api'
import { Loader2, ArrowRight, Check, Eye, EyeOff, Mail } from 'lucide-react'
// Logo Component - Puma Image // Logo Component
function Logo() { function Logo() {
return ( return (
<Image <Image
@ -20,6 +21,26 @@ function Logo() {
) )
} }
// OAuth Icons
function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
)
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
)
}
const benefits = [ const benefits = [
'Track up to 5 domains. Free.', 'Track up to 5 domains. Free.',
'Daily scans. You never miss a drop.', 'Daily scans. You never miss a drop.',
@ -27,8 +48,9 @@ const benefits = [
'Expiry intel. Plan your move.', 'Expiry intel. Plan your move.',
] ]
export default function RegisterPage() { function RegisterForm() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const { register } = useStore() const { register } = useStore()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
@ -36,6 +58,16 @@ export default function RegisterPage() {
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false })
const [registered, setRegistered] = useState(false)
// Get redirect URL from query params
const redirectTo = searchParams.get('redirect') || '/dashboard'
// Load OAuth providers
useEffect(() => {
api.getOAuthProviders().then(setOauthProviders).catch(() => {})
}, [])
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -44,7 +76,8 @@ export default function RegisterPage() {
try { try {
await register(email, password) await register(email, password)
router.push('/dashboard') // Show verification message
setRegistered(true)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed') setError(err instanceof Error ? err.message : 'Registration failed')
} finally { } finally {
@ -52,6 +85,51 @@ export default function RegisterPage() {
} }
} }
// Generate login link with redirect preserved
const loginLink = redirectTo !== '/dashboard'
? `/login?redirect=${encodeURIComponent(redirectTo)}`
: '/login'
// Show verification message after registration
if (registered) {
return (
<div className="min-h-screen flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12 relative">
<div className="fixed inset-0 pointer-events-none">
<div className="absolute top-1/4 left-1/3 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" />
</div>
<div className="relative w-full max-w-md text-center animate-fade-in">
<div className="w-20 h-20 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
<Mail className="w-10 h-10 text-accent" />
</div>
<h2 className="font-display text-3xl sm:text-4xl text-foreground mb-4">
Check your inbox
</h2>
<p className="text-lg text-foreground-muted mb-6">
We&apos;ve sent a verification link to <strong className="text-foreground">{email}</strong>
</p>
<p className="text-sm text-foreground-subtle mb-8">
Click the link in the email to verify your account and start hunting domains.
</p>
<div className="space-y-3">
<Link
href={loginLink}
className="block w-full py-3 bg-foreground text-background text-sm font-medium rounded-xl hover:bg-foreground/90 transition-all"
>
Go to Login
</Link>
<Link
href="/verify-email"
className="block w-full py-3 border border-border text-foreground-muted text-sm font-medium rounded-xl hover:border-foreground/20 transition-all"
>
Resend Verification Email
</Link>
</div>
</div>
</div>
)
}
return ( return (
<div className="min-h-screen flex relative"> <div className="min-h-screen flex relative">
{/* Ambient glow */} {/* Ambient glow */}
@ -69,7 +147,9 @@ export default function RegisterPage() {
{/* Header */} {/* Header */}
<div className="mb-8 sm:mb-10"> <div className="mb-8 sm:mb-10">
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">Join the hunt.</h1> <h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">
Join the hunt.
</h1>
<p className="text-body-sm sm:text-body text-foreground-muted"> <p className="text-body-sm sm:text-body text-foreground-muted">
Start tracking domains in under a minute Start tracking domains in under a minute
</p> </p>
@ -90,18 +170,20 @@ export default function RegisterPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="Email address" placeholder="Email address"
required required
autoComplete="email"
className="input-elegant text-body-sm sm:text-body" className="input-elegant text-body-sm sm:text-body"
/> />
<div className="relative"> <div className="relative">
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Create password (min. 8 characters)" placeholder="Create password (min. 8 characters)"
required required
minLength={8} minLength={8}
autoComplete="new-password"
className="input-elegant text-body-sm sm:text-body pr-12" className="input-elegant text-body-sm sm:text-body pr-12"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
@ -135,10 +217,50 @@ export default function RegisterPage() {
</button> </button>
</form> </form>
{/* OAuth Buttons */}
{(oauthProviders.google_enabled || oauthProviders.github_enabled) && (
<div className="mt-6">
{/* Divider */}
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs">
<span className="px-4 bg-background text-foreground-muted">or continue with</span>
</div>
</div>
<div className="space-y-3">
{oauthProviders.google_enabled && (
<a
href={api.getGoogleLoginUrl(redirectTo)}
className="w-full py-3 sm:py-3.5 bg-[#24292e] text-white text-sm font-medium rounded-xl
hover:bg-[#2f363d] border border-[#24292e]
transition-all duration-300 flex items-center justify-center gap-3"
>
<GoogleIcon className="w-5 h-5" />
Sign up with Google
</a>
)}
{oauthProviders.github_enabled && (
<a
href={api.getGitHubLoginUrl(redirectTo)}
className="w-full py-3 sm:py-3.5 bg-[#24292e] text-white text-sm font-medium rounded-xl
hover:bg-[#2f363d] border border-[#24292e]
transition-all duration-300 flex items-center justify-center gap-3"
>
<GitHubIcon className="w-5 h-5" />
Sign up with GitHub
</a>
)}
</div>
</div>
)}
{/* Login Link */} {/* Login Link */}
<p className="mt-8 sm:mt-10 text-body-xs sm:text-body-sm text-foreground-muted"> <p className="mt-8 sm:mt-10 text-body-xs sm:text-body-sm text-foreground-muted">
Already have an account?{' '} Already have an account?{' '}
<Link href="/login" className="text-foreground hover:text-accent transition-colors duration-300"> <Link href={loginLink} className="text-foreground hover:text-accent transition-colors duration-300">
Sign in Sign in
</Link> </Link>
</p> </p>
@ -178,3 +300,15 @@ export default function RegisterPage() {
</div> </div>
) )
} }
export default function RegisterPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
}>
<RegisterForm />
</Suspense>
)
}

View File

@ -182,18 +182,32 @@ export default function SettingsPage() {
] ]
return ( return (
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Background Effects - matching landing page */}
<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
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div>
<Header /> <Header />
<main className="flex-1 pt-28 sm:pt-32 pb-20 sm:pb-24 px-4 sm:px-6 lg:px-8"> <main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<div className="mb-10"> <div className="mb-12 sm:mb-16 animate-fade-in">
<h1 className="font-display text-[2rem] sm:text-[2.5rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2"> <span className="text-sm font-semibold text-accent uppercase tracking-wider">Settings</span>
Settings <h1 className="mt-4 font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
Your account.
</h1> </h1>
<p className="text-body text-foreground-muted"> <p className="mt-3 text-lg text-foreground-muted">
Your account. Your rules. Your rules. Configure everything in one place.
</p> </p>
</div> </div>
@ -218,9 +232,9 @@ export default function SettingsPage() {
</div> </div>
)} )}
<div className="flex flex-col lg:flex-row gap-8"> <div className="flex flex-col lg:flex-row gap-8 animate-slide-up">
{/* Sidebar - Horizontal scroll on mobile, vertical on desktop */} {/* Sidebar - Horizontal scroll on mobile, vertical on desktop */}
<div className="lg:w-64 shrink-0"> <div className="lg:w-72 shrink-0">
{/* Mobile: Horizontal scroll tabs */} {/* Mobile: Horizontal scroll tabs */}
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide"> <nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
{tabs.map((tab) => ( {tabs.map((tab) => (
@ -228,10 +242,10 @@ export default function SettingsPage() {
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={clsx( className={clsx(
"flex items-center gap-2 px-5 py-2.5 text-ui font-medium rounded-xl whitespace-nowrap transition-all", "flex items-center gap-2.5 px-5 py-3 text-sm font-medium rounded-xl whitespace-nowrap transition-all duration-300",
activeTab === tab.id activeTab === tab.id
? "bg-foreground text-background shadow-lg" ? "bg-accent text-background shadow-lg shadow-accent/20"
: "bg-background-secondary text-foreground-muted hover:text-foreground border border-border" : "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border hover:border-accent/30"
)} )}
> >
<tab.icon className="w-4 h-4" /> <tab.icon className="w-4 h-4" />
@ -241,15 +255,15 @@ export default function SettingsPage() {
</nav> </nav>
{/* Desktop: Vertical tabs */} {/* Desktop: Vertical tabs */}
<nav className="hidden lg:block p-1.5 bg-background-secondary/70 border border-border rounded-2xl"> <nav className="hidden lg:block p-2 bg-background-secondary/50 border border-border rounded-2xl">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={clsx( className={clsx(
"w-full flex items-center gap-3 px-4 py-3 text-ui font-medium rounded-xl transition-all", "w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium rounded-xl transition-all duration-300",
activeTab === tab.id activeTab === tab.id
? "bg-foreground text-background shadow-lg" ? "bg-accent text-background shadow-lg shadow-accent/20"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5" : "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)} )}
> >
@ -260,13 +274,13 @@ export default function SettingsPage() {
</nav> </nav>
{/* Plan info - hidden on mobile, shown in content area instead */} {/* Plan info - hidden on mobile, shown in content area instead */}
<div className="hidden lg:block mt-4 p-5 bg-accent/5 border border-accent/20 rounded-2xl"> <div className="hidden lg:block mt-5 p-6 bg-accent/5 border border-accent/20 rounded-2xl">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-3">
{isProOrHigher ? <Crown className="w-4 h-4 text-accent" /> : <Zap className="w-4 h-4 text-accent" />} {isProOrHigher ? <Crown className="w-5 h-5 text-accent" /> : <Zap className="w-5 h-5 text-accent" />}
<span className="text-body-sm font-medium text-foreground">{tierName} Plan</span> <span className="text-sm font-semibold text-foreground">{tierName} Plan</span>
</div> </div>
<p className="text-body-xs text-foreground-muted mb-3"> <p className="text-xs text-foreground-muted mb-4">
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains {subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains tracked
</p> </p>
{!isProOrHigher && ( {!isProOrHigher && (
<Link <Link
@ -495,18 +509,53 @@ export default function SettingsPage() {
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-body-sm font-medium text-foreground">Plan Features</h3> <h3 className="text-body-sm font-medium text-foreground">Plan Features</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{subscription?.features && Object.entries(subscription.features).map(([key, value]) => ( {subscription?.features && Object.entries(subscription.features)
<li key={key} className="flex items-center gap-2 text-body-sm"> .filter(([key]) => !['sms_alerts', 'api_access', 'webhooks', 'bulk_tools', 'seo_metrics'].includes(key))
{value ? ( .map(([key, value]) => {
<Check className="w-4 h-4 text-accent" /> const featureNames: Record<string, string> = {
) : ( email_alerts: 'Email Alerts',
<span className="w-4 h-4 text-foreground-subtle"></span> priority_alerts: 'Priority Alerts',
)} full_whois: 'Full WHOIS Data',
<span className={value ? 'text-foreground' : 'text-foreground-muted'}> expiration_tracking: 'Expiry Tracking',
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} domain_valuation: 'Domain Valuation',
market_insights: 'Market Insights',
}
return (
<li key={key} className="flex items-center gap-2 text-body-sm">
{value ? (
<Check className="w-4 h-4 text-accent" />
) : (
<span className="w-4 h-4 text-foreground-subtle"></span>
)}
<span className={value ? 'text-foreground' : 'text-foreground-muted'}>
{featureNames[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
</span>
</li>
)
})}
{/* Show additional plan info */}
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">
{subscription?.domain_limit} Watchlist Domains
</span>
</li>
{(subscription?.portfolio_limit ?? 0) !== 0 && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">
{subscription?.portfolio_limit === -1 ? 'Unlimited' : subscription?.portfolio_limit} Portfolio Domains
</span> </span>
</li> </li>
))} )}
{(subscription?.history_days ?? 0) !== 0 && (
<li className="flex items-center gap-2 text-body-sm">
<Check className="w-4 h-4 text-accent" />
<span className="text-foreground">
{subscription?.history_days === -1 ? 'Full' : `${subscription?.history_days}-day`} Price History
</span>
</li>
)}
</ul> </ul>
</div> </div>
</div> </div>

View File

@ -391,9 +391,12 @@ function DomainResultCard({
export default function TldDetailPage() { export default function TldDetailPage() {
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() const { isAuthenticated, checkAuth, isLoading: authLoading, subscription, fetchSubscription } = useStore()
const tld = params.tld as string const tld = params.tld as string
// Feature flags based on subscription
const hasPriceHistory = (subscription?.history_days ?? 0) !== 0
const [details, setDetails] = useState<TldDetails | null>(null) const [details, setDetails] = useState<TldDetails | null>(null)
const [history, setHistory] = useState<TldHistory | null>(null) const [history, setHistory] = useState<TldHistory | null>(null)
const [relatedTlds, setRelatedTlds] = useState<Array<{ tld: string; price: number }>>([]) const [relatedTlds, setRelatedTlds] = useState<Array<{ tld: string; price: number }>>([])
@ -408,7 +411,8 @@ export default function TldDetailPage() {
useEffect(() => { useEffect(() => {
checkAuth() checkAuth()
}, [checkAuth]) fetchSubscription()
}, [checkAuth, fetchSubscription])
useEffect(() => { useEffect(() => {
if (tld) { if (tld) {
@ -797,8 +801,13 @@ export default function TldDetailPage() {
{/* Price Chart */} {/* Price Chart */}
<section className="mb-12"> <section className="mb-12">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-body-lg font-medium text-foreground">Price History</h2> <div className="flex items-center gap-3">
{isAuthenticated && ( <h2 className="text-body-lg font-medium text-foreground">Price History</h2>
{isAuthenticated && !hasPriceHistory && (
<span className="text-ui-xs px-2 py-0.5 rounded-md bg-accent/10 text-accent">Pro</span>
)}
</div>
{hasPriceHistory && (
<div className="flex items-center gap-1 p-1 bg-background-secondary/50 border border-border/50 rounded-lg"> <div className="flex items-center gap-1 p-1 bg-background-secondary/50 border border-border/50 rounded-lg">
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => ( {(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => (
<button <button
@ -819,29 +828,56 @@ export default function TldDetailPage() {
</div> </div>
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl"> <div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
<PriceChart {!isAuthenticated ? (
data={filteredHistory} <div className="relative h-48 flex items-center justify-center">
isAuthenticated={isAuthenticated} <div className="absolute inset-0 bg-gradient-to-t from-background-secondary/50 to-transparent rounded-xl" />
chartStats={chartStats} <div className="relative z-10 flex flex-col items-center gap-3">
/> <Lock className="w-5 h-5 text-foreground-subtle" />
<span className="text-ui-sm text-foreground-muted">Sign in to view price history</span>
{isAuthenticated && filteredHistory.length > 0 && ( <Link href={`/login?redirect=/tld-pricing/${tld}`} className="text-ui-sm text-accent hover:text-accent-hover transition-colors">
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border/30 text-ui-sm"> Sign in
<span className="text-foreground-subtle"> </Link>
{new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-foreground-subtle">High</span>
<span className="text-foreground font-medium tabular-nums">${chartStats.high.toFixed(2)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-foreground-subtle">Low</span>
<span className="text-accent font-medium tabular-nums">${chartStats.low.toFixed(2)}</span>
</div>
</div> </div>
<span className="text-foreground-subtle">Today</span>
</div> </div>
) : !hasPriceHistory ? (
<div className="relative h-48 flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-t from-background-secondary/50 to-transparent rounded-xl" />
<div className="relative z-10 flex flex-col items-center gap-3">
<Zap className="w-5 h-5 text-accent" />
<span className="text-ui-sm text-foreground-muted">Price history requires Trader or Tycoon plan</span>
<Link href="/pricing" className="flex items-center gap-2 text-ui-sm px-4 py-2 bg-accent text-background rounded-lg hover:bg-accent-hover transition-all">
<Zap className="w-4 h-4" />
Upgrade to Unlock
</Link>
</div>
</div>
) : (
<>
<PriceChart
data={filteredHistory}
isAuthenticated={true}
chartStats={chartStats}
/>
{filteredHistory.length > 0 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border/30 text-ui-sm">
<span className="text-foreground-subtle">
{new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</span>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-foreground-subtle">High</span>
<span className="text-foreground font-medium tabular-nums">${chartStats.high.toFixed(2)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-foreground-subtle">Low</span>
<span className="text-accent font-medium tabular-nums">${chartStats.low.toFixed(2)}</span>
</div>
</div>
<span className="text-foreground-subtle">Today</span>
</div>
)}
</>
)} )}
</div> </div>
</section> </section>

View File

@ -258,26 +258,31 @@ export default function TldPricingPage() {
} }
return ( return (
<div className="min-h-screen bg-background relative flex flex-col"> <div className="min-h-screen bg-background relative overflow-hidden">
{/* Ambient glow */} {/* Background Effects - matching landing page */}
<div className="fixed inset-0 pointer-events-none"> <div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" /> <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
className="absolute inset-0 opacity-[0.015]"
style={{
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
backgroundSize: '64px 64px',
}}
/>
</div> </div>
<Header /> <Header />
<main className="relative pt-28 sm:pt-32 pb-16 sm:pb-20 px-4 sm:px-6 flex-1"> <main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
{/* Header */} {/* Header */}
<div className="text-center mb-12 sm:mb-16 animate-fade-in"> <div className="text-center mb-16 sm:mb-20 animate-fade-in">
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6"> <span className="text-sm font-semibold text-accent uppercase tracking-wider">Market Intel</span>
<BarChart3 className="w-4 h-4 text-accent" /> <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">
<span className="text-ui-sm text-foreground-muted">Market Intel</span>
</div>
<h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4">
{pagination.total}+ TLDs. Live Prices. {pagination.total}+ TLDs. Live Prices.
</h1> </h1>
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto"> <p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
See what domains cost. Spot trends. Find opportunities. See what domains cost. Spot trends. Find opportunities.
</p> </p>
</div> </div>

View File

@ -1,128 +1,218 @@
'use client' 'use client'
import { useState, useEffect, Suspense } from 'react' import { useEffect, useState, Suspense } from 'react'
import { useSearchParams, useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Header } from '@/components/Header' import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer' import { Footer } from '@/components/Footer'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Mail, CheckCircle, AlertCircle, Loader2 } from 'lucide-react' import { CheckCircle, XCircle, Loader2, Mail, ArrowRight } from 'lucide-react'
import Link from 'next/link'
function VerifyEmailContent() { function VerifyEmailContent() {
const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const token = searchParams.get('token') const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading') const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'no-token'>('loading')
const [error, setError] = useState<string | null>(null) const [message, setMessage] = useState('')
const [email, setEmail] = useState('')
const [resending, setResending] = useState(false)
const [resendSuccess, setResendSuccess] = useState(false)
useEffect(() => { useEffect(() => {
if (!token) { if (!token) {
setStatus('error') setStatus('no-token')
setError('Invalid or missing verification token.')
return return
} }
const verifyEmail = async () => {
try {
await api.verifyEmail(token)
setStatus('success')
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login?verified=true')
}, 3000)
} catch (err: any) {
setStatus('error')
setError(err.message || 'Failed to verify email. The link may have expired.')
}
}
verifyEmail() verifyEmail()
}, [token, router]) }, [token])
const verifyEmail = async () => {
try {
const response = await api.verifyEmail(token!)
setStatus('success')
setMessage(response.message)
// Redirect to login after 3 seconds
setTimeout(() => {
router.push('/login?verified=true')
}, 3000)
} catch (err: any) {
setStatus('error')
setMessage(err.message || 'Verification failed. The link may be expired.')
}
}
const handleResend = async (e: React.FormEvent) => {
e.preventDefault()
if (!email) return
setResending(true)
try {
await api.resendVerification(email)
setResendSuccess(true)
} catch (err) {
// Always show success for security
setResendSuccess(true)
} finally {
setResending(false)
}
}
return ( return (
<> <div className="min-h-screen bg-background relative overflow-hidden">
<Header /> {/* Background Effects */}
<main className="min-h-screen flex items-center justify-center p-4 pt-24"> <div className="fixed inset-0 pointer-events-none">
<div className="w-full max-w-md"> <div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
{status === 'loading' && ( <div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center"> <div
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6"> className="absolute inset-0 opacity-[0.015]"
<Loader2 className="w-8 h-8 text-accent animate-spin" /> style={{
</div> backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
<h1 className="text-display-sm font-bold text-foreground mb-4"> backgroundSize: '64px 64px',
Verifying your email... }}
</h1> />
<p className="text-foreground-muted"> </div>
Please wait while we verify your email address.
</p>
</div>
)}
{status === 'success' && ( <Header />
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6"> <main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
<CheckCircle className="w-8 h-8 text-accent" /> <div className="max-w-md mx-auto">
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 px-4 py-2 bg-accent/10 border border-accent/20 rounded-full mb-6">
<Mail className="w-4 h-4 text-accent" />
<span className="text-sm font-medium text-accent">Email Verification</span>
</div>
</div>
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 backdrop-blur-sm">
{status === 'loading' && (
<div className="text-center py-8">
<Loader2 className="w-12 h-12 text-accent animate-spin mx-auto mb-4" />
<p className="text-lg text-foreground">Verifying your email...</p>
<p className="text-sm text-foreground-muted mt-2">This will only take a moment</p>
</div> </div>
<h1 className="text-display-sm font-bold text-foreground mb-4"> )}
Email verified!
</h1> {status === 'success' && (
<p className="text-foreground-muted mb-6"> <div className="text-center py-8">
Your email has been verified successfully. Redirecting you to login... <div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-4">
</p> <CheckCircle className="w-8 h-8 text-accent" />
</div>
<h2 className="text-2xl font-display text-foreground mb-2">Email Verified!</h2>
<p className="text-foreground-muted mb-6">{message}</p>
<p className="text-sm text-foreground-subtle">Redirecting to login...</p>
</div>
)}
{status === 'error' && (
<div className="text-center py-8">
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
<XCircle className="w-8 h-8 text-red-500" />
</div>
<h2 className="text-2xl font-display text-foreground mb-2">Verification Failed</h2>
<p className="text-foreground-muted mb-6">{message}</p>
{!resendSuccess ? (
<form onSubmit={handleResend} className="mt-6">
<p className="text-sm text-foreground-muted mb-4">
Need a new verification link?
</p>
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
className="flex-1 px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
required
/>
<button
type="submit"
disabled={resending}
className="px-4 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all disabled:opacity-50"
>
{resending ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Resend'}
</button>
</div>
</form>
) : (
<div className="mt-6 p-4 bg-accent/10 border border-accent/20 rounded-xl">
<p className="text-sm text-accent">
If an unverified account exists, a new verification link has been sent.
</p>
</div>
)}
</div>
)}
{status === 'no-token' && (
<div className="text-center py-8">
<div className="w-16 h-16 bg-foreground/5 rounded-full flex items-center justify-center mx-auto mb-4">
<Mail className="w-8 h-8 text-foreground-muted" />
</div>
<h2 className="text-2xl font-display text-foreground mb-2">Verify Your Email</h2>
<p className="text-foreground-muted mb-6">
Check your inbox for a verification link, or request a new one below.
</p>
{!resendSuccess ? (
<form onSubmit={handleResend} className="mt-6">
<div className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email"
className="flex-1 px-4 py-3 bg-background border border-border rounded-xl text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50"
required
/>
<button
type="submit"
disabled={resending}
className="px-4 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all disabled:opacity-50"
>
{resending ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Send'}
</button>
</div>
</form>
) : (
<div className="mt-6 p-4 bg-accent/10 border border-accent/20 rounded-xl">
<p className="text-sm text-accent">
If an unverified account exists, a verification link has been sent.
</p>
</div>
)}
</div>
)}
<div className="mt-8 pt-6 border-t border-border text-center">
<Link <Link
href="/login" href="/login"
className="inline-flex items-center justify-center px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all" className="inline-flex items-center gap-2 text-sm text-foreground-muted hover:text-accent transition-colors"
> >
Go to login Back to Login
<ArrowRight className="w-4 h-4" />
</Link> </Link>
</div> </div>
)} </div>
{status === 'error' && (
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
<div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertCircle className="w-8 h-8 text-danger" />
</div>
<h1 className="text-display-sm font-bold text-foreground mb-4">
Verification failed
</h1>
<p className="text-foreground-muted mb-6">
{error}
</p>
<div className="space-y-3">
<Link
href="/login"
className="inline-flex items-center justify-center w-full px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
>
Go to login
</Link>
<button
onClick={() => window.location.reload()}
className="inline-flex items-center justify-center w-full px-6 py-3 bg-background-tertiary text-foreground font-medium rounded-xl hover:bg-background-secondary transition-all border border-border"
>
Try again
</button>
</div>
</div>
)}
</div> </div>
</main> </main>
<Footer /> <Footer />
</> </div>
) )
} }
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
return ( return (
<Suspense fallback={ <Suspense fallback={
<main className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-foreground-muted">Loading...</div> <Loader2 className="w-6 h-6 animate-spin text-accent" />
</main> </div>
}> }>
<VerifyEmailContent /> <VerifyEmailContent />
</Suspense> </Suspense>
) )
} }

View File

@ -0,0 +1,57 @@
'use client'
import Link from 'next/link'
import { ChevronRight, Home } from 'lucide-react'
import clsx from 'clsx'
export interface BreadcrumbItem {
label: string
href?: string
}
interface BreadcrumbsProps {
items: BreadcrumbItem[]
className?: string
}
export function Breadcrumbs({ items, className }: BreadcrumbsProps) {
return (
<nav
aria-label="Breadcrumb"
className={clsx("flex items-center gap-1 text-body-sm", className)}
>
<Link
href="/"
className="flex items-center gap-1 text-foreground-muted hover:text-foreground transition-colors"
>
<Home className="w-4 h-4" />
<span className="sr-only">Home</span>
</Link>
{items.map((item, index) => {
const isLast = index === items.length - 1
return (
<div key={index} className="flex items-center gap-1">
<ChevronRight className="w-4 h-4 text-foreground-subtle" />
{isLast || !item.href ? (
<span className={clsx(
isLast ? "text-foreground font-medium" : "text-foreground-muted"
)}>
{item.label}
</span>
) : (
<Link
href={item.href}
className="text-foreground-muted hover:text-foreground transition-colors"
>
{item.label}
</Link>
)}
</div>
)
})}
</nav>
)
}

View File

@ -1,8 +1,13 @@
'use client'
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image' import Image from 'next/image'
import { Github, Twitter, Mail } from 'lucide-react' import { Github, Twitter, Mail } from 'lucide-react'
import { useStore } from '@/lib/store'
export function Footer() { export function Footer() {
const { isAuthenticated } = useStore()
return ( return (
<footer className="relative border-t border-border bg-background-secondary/30 backdrop-blur-sm mt-auto"> <footer className="relative border-t border-border bg-background-secondary/30 backdrop-blur-sm mt-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16"> <div className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16">
@ -10,13 +15,15 @@ export function Footer() {
{/* Brand */} {/* Brand */}
<div className="col-span-2 md:col-span-1"> <div className="col-span-2 md:col-span-1">
<div className="mb-4"> <div className="mb-4">
<Image <Link href="/" className="inline-block">
src="/pounce-logo.png" <Image
alt="pounce" src="/pounce-logo.png"
width={120} alt="pounce"
height={60} width={120}
className="w-28 h-auto" height={60}
/> className="w-28 h-auto"
/>
</Link>
</div> </div>
<p className="text-body-sm text-foreground-muted mb-4 max-w-xs"> <p className="text-body-sm text-foreground-muted mb-4 max-w-xs">
Domain intelligence for hunters. Track. Alert. Pounce. Domain intelligence for hunters. Track. Alert. Pounce.
@ -27,6 +34,7 @@ export function Footer() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors" className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
aria-label="GitHub"
> >
<Github className="w-4 h-4 text-foreground-muted" /> <Github className="w-4 h-4 text-foreground-muted" />
</a> </a>
@ -35,59 +43,63 @@ export function Footer() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors" className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
aria-label="Twitter"
> >
<Twitter className="w-4 h-4 text-foreground-muted" /> <Twitter className="w-4 h-4 text-foreground-muted" />
</a> </a>
<a <a
href="mailto:support@pounce.dev" href="mailto:support@pounce.dev"
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors" className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
aria-label="Email"
> >
<Mail className="w-4 h-4 text-foreground-muted" /> <Mail className="w-4 h-4 text-foreground-muted" />
</a> </a>
</div> </div>
</div> </div>
{/* Product */} {/* Product - Matches Header nav */}
<div> <div>
<h3 className="text-ui font-medium text-foreground mb-4">Product</h3> <h3 className="text-ui font-medium text-foreground mb-4">Product</h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li> <li>
<Link href="/" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors"> <Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Domain Monitoring TLD Prices
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors"> <Link href="/auctions" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
TLD Pricing Auctions
</Link> </Link>
</li> </li>
<li> <li>
<Link href="/pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors"> <Link href="/pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Pricing Plans Pricing
</Link>
</li>
<li>
<Link href="/dashboard" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Dashboard
</Link> </Link>
</li> </li>
{isAuthenticated && (
<li>
<Link href="/dashboard" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Command Center
</Link>
</li>
)}
</ul> </ul>
</div> </div>
{/* Company */} {/* Resources */}
<div> <div>
<h3 className="text-ui font-medium text-foreground mb-4">Company</h3> <h3 className="text-ui font-medium text-foreground mb-4">Resources</h3>
<ul className="space-y-3"> <ul className="space-y-3">
<li>
<Link href="/about" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
About
</Link>
</li>
<li> <li>
<Link href="/blog" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors"> <Link href="/blog" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Blog Blog
</Link> </Link>
</li> </li>
<li>
<Link href="/about" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
About
</Link>
</li>
<li> <li>
<Link href="/contact" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors"> <Link href="/contact" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Contact Contact
@ -120,11 +132,6 @@ export function Footer() {
Imprint Imprint
</Link> </Link>
</li> </li>
<li>
<Link href="/unsubscribe" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
Unsubscribe
</Link>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -150,5 +157,3 @@ export function Footer() {
</footer> </footer>
) )
} }

View File

@ -1,12 +1,28 @@
'use client' 'use client'
import Link from 'next/link' import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useStore } from '@/lib/store' import { useStore } from '@/lib/store'
import { LogOut, LayoutDashboard, Menu, X, Settings, Bell, User, ChevronDown, TrendingUp, Briefcase, Eye } from 'lucide-react' import {
LogOut,
LayoutDashboard,
Menu,
X,
Settings,
Bell,
User,
ChevronDown,
TrendingUp,
Gavel,
CreditCard,
Search,
Shield,
} from 'lucide-react'
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
export function Header() { export function Header() {
const pathname = usePathname()
const { isAuthenticated, user, logout, domains, subscription } = useStore() const { isAuthenticated, user, logout, domains, subscription } = useStore()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false) const [userMenuOpen, setUserMenuOpen] = useState(false)
@ -28,18 +44,35 @@ export function Header() {
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, []) }, [])
// Close mobile menu on route change
useEffect(() => {
setMobileMenuOpen(false)
}, [pathname])
// Count notifications (available domains, etc.) // Count notifications (available domains, etc.)
const availableDomains = domains?.filter(d => d.is_available) || [] const availableDomains = domains?.filter(d => d.is_available) || []
const hasNotifications = availableDomains.length > 0 const hasNotifications = availableDomains.length > 0
const tierName = subscription?.tier_name || subscription?.tier || 'Scout' const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
// Navigation items - consistent for logged in/out
const navItems = [
{ href: '/tld-pricing', label: 'TLD Prices', icon: TrendingUp },
{ href: '/auctions', label: 'Auctions', icon: Gavel },
{ href: '/pricing', label: 'Pricing', icon: CreditCard },
]
const isActive = (href: string) => {
if (href === '/') return pathname === '/'
return pathname.startsWith(href)
}
return ( return (
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border-subtle"> <header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border-subtle">
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 sm:h-20 flex items-center justify-between"> <div className="w-full px-4 sm:px-6 lg:px-8 h-16 sm:h-20 flex items-center justify-between">
{/* Left side: Logo + Nav Links */} {/* Left side: Logo + Nav Links */}
<div className="flex items-center gap-6 sm:gap-8 h-full"> <div className="flex items-center gap-6 sm:gap-8 h-full">
{/* Logo - Playfair Display font */} {/* Logo */}
<Link <Link
href="/" href="/"
className="flex items-center h-full hover:opacity-80 transition-opacity duration-300" className="flex items-center h-full hover:opacity-80 transition-opacity duration-300"
@ -52,53 +85,41 @@ export function Header() {
</span> </span>
</Link> </Link>
{/* Left Nav Links (Desktop) - vertically centered */} {/* Main Nav Links (Desktop) */}
<nav className="hidden sm:flex items-center h-full gap-1"> <nav className="hidden md:flex items-center h-full gap-1">
<Link {navItems.map((item) => (
href="/"
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
hover:bg-background-secondary rounded-lg transition-all duration-300"
>
Domain
</Link>
<Link
href="/tld-pricing"
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
hover:bg-background-secondary rounded-lg transition-all duration-300"
>
TLD
</Link>
<Link
href="/auctions"
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
hover:bg-background-secondary rounded-lg transition-all duration-300"
>
Auctions
</Link>
{!isAuthenticated && (
<Link <Link
href="/pricing" key={item.href}
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground href={item.href}
hover:bg-background-secondary rounded-lg transition-all duration-300" className={clsx(
"flex items-center h-9 px-3 text-[0.8125rem] rounded-lg transition-all duration-200",
isActive(item.href)
? "text-foreground bg-foreground/5 font-medium"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
> >
Plans {item.label}
</Link> </Link>
)} ))}
</nav> </nav>
</div> </div>
{/* Right side: Auth Links - vertically centered */} {/* Right side */}
<nav className="hidden sm:flex items-center h-full gap-2"> <nav className="hidden sm:flex items-center h-full gap-2">
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
{/* Dashboard Link */} {/* Command Center Link - Primary CTA when logged in */}
<Link <Link
href="/dashboard" href="/dashboard"
className="flex items-center gap-2 h-9 px-4 text-[0.8125rem] font-medium text-foreground className={clsx(
bg-foreground/5 hover:bg-foreground/10 rounded-lg transition-all duration-300" "flex items-center gap-2 h-9 px-4 text-[0.8125rem] font-medium rounded-lg transition-all duration-200",
isActive('/dashboard')
? "bg-foreground text-background"
: "text-foreground bg-foreground/5 hover:bg-foreground/10"
)}
> >
<LayoutDashboard className="w-4 h-4" /> <LayoutDashboard className="w-4 h-4" />
<span>Dashboard</span> <span>Command Center</span>
</Link> </Link>
{/* Notifications */} {/* Notifications */}
@ -106,8 +127,10 @@ export function Header() {
<button <button
onClick={() => setNotificationsOpen(!notificationsOpen)} onClick={() => setNotificationsOpen(!notificationsOpen)}
className={clsx( className={clsx(
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300", "relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
notificationsOpen ? "bg-foreground/10 text-foreground" : "text-foreground-muted hover:text-foreground hover:bg-foreground/5" notificationsOpen
? "bg-foreground/10 text-foreground"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)} )}
> >
<Bell className="w-4 h-4" /> <Bell className="w-4 h-4" />
@ -127,7 +150,7 @@ export function Header() {
<div className="max-h-80 overflow-y-auto"> <div className="max-h-80 overflow-y-auto">
{availableDomains.length > 0 ? ( {availableDomains.length > 0 ? (
<div className="p-2"> <div className="p-2">
{availableDomains.map((domain) => ( {availableDomains.slice(0, 5).map((domain) => (
<Link <Link
key={domain.id} key={domain.id}
href="/dashboard" href="/dashboard"
@ -135,14 +158,19 @@ export function Header() {
className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors" className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors"
> >
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center shrink-0"> <div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center shrink-0">
<Eye className="w-4 h-4 text-accent" /> <Search className="w-4 h-4 text-accent" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-body-sm font-medium text-foreground truncate">{domain.name}</p> <p className="text-body-sm font-medium text-foreground truncate">{domain.name}</p>
<p className="text-body-xs text-accent">Domain is available!</p> <p className="text-body-xs text-accent">Available now!</p>
</div> </div>
</Link> </Link>
))} ))}
{availableDomains.length > 5 && (
<p className="px-3 py-2 text-body-xs text-foreground-muted">
+{availableDomains.length - 5} more available
</p>
)}
</div> </div>
) : ( ) : (
<div className="p-8 text-center"> <div className="p-8 text-center">
@ -157,7 +185,7 @@ export function Header() {
onClick={() => setNotificationsOpen(false)} onClick={() => setNotificationsOpen(false)}
className="block p-3 text-center text-body-xs text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-t border-border transition-colors" className="block p-3 text-center text-body-xs text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-t border-border transition-colors"
> >
Manage notifications Notification settings
</Link> </Link>
</div> </div>
)} )}
@ -168,14 +196,17 @@ export function Header() {
<button <button
onClick={() => setUserMenuOpen(!userMenuOpen)} onClick={() => setUserMenuOpen(!userMenuOpen)}
className={clsx( className={clsx(
"flex items-center gap-2 h-9 pl-3 pr-2 rounded-lg transition-all duration-300", "flex items-center gap-2 h-9 pl-3 pr-2 rounded-lg transition-all duration-200",
userMenuOpen ? "bg-foreground/10" : "hover:bg-foreground/5" userMenuOpen ? "bg-foreground/10" : "hover:bg-foreground/5"
)} )}
> >
<div className="w-6 h-6 bg-accent/10 rounded-full flex items-center justify-center"> <div className="w-6 h-6 bg-accent/10 rounded-full flex items-center justify-center">
<User className="w-3.5 h-3.5 text-accent" /> <User className="w-3.5 h-3.5 text-accent" />
</div> </div>
<ChevronDown className={clsx("w-3.5 h-3.5 text-foreground-muted transition-transform", userMenuOpen && "rotate-180")} /> <ChevronDown className={clsx(
"w-3.5 h-3.5 text-foreground-muted transition-transform duration-200",
userMenuOpen && "rotate-180"
)} />
</button> </button>
{/* User Dropdown */} {/* User Dropdown */}
@ -186,29 +217,23 @@ export function Header() {
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p> <p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
<p className="text-body-xs text-foreground-muted truncate">{user?.email}</p> <p className="text-body-xs text-foreground-muted truncate">{user?.email}</p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<span className="text-ui-xs px-2 py-0.5 bg-foreground/5 text-foreground-muted rounded-full">{tierName}</span> <span className="text-ui-xs px-2 py-0.5 bg-accent/10 text-accent rounded-full font-medium">{tierName}</span>
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span> <span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
</div> </div>
</div> </div>
{/* Menu Items */} {/* Menu Items */}
<div className="p-2"> <div className="p-2">
<Link {user?.is_admin && (
href="/dashboard" <Link
onClick={() => setUserMenuOpen(false)} href="/admin"
className="flex items-center gap-3 px-3 py-2.5 text-body-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors" onClick={() => setUserMenuOpen(false)}
> className="flex items-center gap-3 px-3 py-2.5 text-body-sm text-accent hover:bg-accent/10 rounded-lg transition-colors"
<LayoutDashboard className="w-4 h-4" /> >
Command Center <Shield className="w-4 h-4" />
</Link> Admin Panel
<Link </Link>
href="/tld-pricing" )}
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 text-body-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
>
<TrendingUp className="w-4 h-4" />
TLD Pricing
</Link>
<Link <Link
href="/settings" href="/settings"
onClick={() => setUserMenuOpen(false)} onClick={() => setUserMenuOpen(false)}
@ -241,14 +266,14 @@ export function Header() {
<Link <Link
href="/login" href="/login"
className="flex items-center h-9 px-4 text-[0.8125rem] text-foreground-muted hover:text-foreground className="flex items-center h-9 px-4 text-[0.8125rem] text-foreground-muted hover:text-foreground
hover:bg-background-secondary rounded-lg transition-all duration-300" hover:bg-foreground/5 rounded-lg transition-all duration-200"
> >
Sign In Sign In
</Link> </Link>
<Link <Link
href="/register" href="/register"
className="flex items-center h-9 ml-2 px-5 text-[0.8125rem] bg-foreground text-background rounded-lg className="flex items-center h-9 ml-1 px-5 text-[0.8125rem] bg-foreground text-background rounded-lg
font-medium hover:bg-foreground/90 transition-all duration-300" font-medium hover:bg-foreground/90 transition-all duration-200"
> >
Get Started Get Started
</Link> </Link>
@ -268,54 +293,71 @@ export function Header() {
{/* Mobile Menu */} {/* Mobile Menu */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div className="sm:hidden border-t border-border bg-background/95 backdrop-blur-xl"> <div className="sm:hidden border-t border-border bg-background/95 backdrop-blur-xl">
<nav className="px-4 py-4 space-y-2"> <nav className="px-4 py-4 space-y-1">
{isAuthenticated ? ( {isAuthenticated && (
<> <>
{/* User Info on Mobile */} {/* User Info on Mobile */}
<div className="px-4 py-3 mb-2 border-b border-border"> <div className="px-4 py-3 mb-3 bg-foreground/5 rounded-xl">
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p> <p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-ui-xs px-2 py-0.5 bg-foreground/5 text-foreground-muted rounded-full">{tierName}</span> <span className="text-ui-xs px-2 py-0.5 bg-accent/10 text-accent rounded-full font-medium">{tierName}</span>
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span> <span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
</div> </div>
</div> </div>
<Link <Link
href="/dashboard" href="/dashboard"
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted className={clsx(
hover:text-foreground hover:bg-background-secondary rounded-xl "flex items-center gap-3 px-4 py-3 text-body-sm rounded-xl transition-all duration-200",
transition-all duration-300" isActive('/dashboard')
onClick={() => setMobileMenuOpen(false)} ? "bg-foreground text-background font-medium"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
> >
<LayoutDashboard className="w-5 h-5" /> <LayoutDashboard className="w-5 h-5" />
<span>Dashboard</span> <span>Command Center</span>
</Link> {hasNotifications && (
<Link <span className="ml-auto w-2 h-2 bg-accent rounded-full" />
href="/tld-pricing" )}
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
>
<TrendingUp className="w-5 h-5" />
<span>TLD Pricing</span>
</Link>
<Link
href="/auctions"
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
>
<Briefcase className="w-5 h-5" />
<span>Auctions</span>
</Link> </Link>
</>
)}
{/* Main Nav */}
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={clsx(
"flex items-center gap-3 px-4 py-3 text-body-sm rounded-xl transition-all duration-200",
isActive(item.href)
? "bg-foreground/10 text-foreground font-medium"
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
)}
>
<item.icon className="w-5 h-5" />
<span>{item.label}</span>
</Link>
))}
{isAuthenticated ? (
<>
<div className="my-3 border-t border-border" />
{user?.is_admin && (
<Link
href="/admin"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center gap-3 px-4 py-3 text-body-sm text-accent
hover:bg-accent/10 rounded-xl transition-all duration-200"
>
<Shield className="w-5 h-5" />
<span>Admin Panel</span>
</Link>
)}
<Link <Link
href="/settings" href="/settings"
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
> >
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
<span>Settings</span> <span>Settings</span>
@ -326,8 +368,7 @@ export function Header() {
setMobileMenuOpen(false) setMobileMenuOpen(false)
}} }}
className="flex items-center gap-3 w-full px-4 py-3 text-body-sm text-foreground-muted className="flex items-center gap-3 w-full px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
transition-all duration-300"
> >
<LogOut className="w-5 h-5" /> <LogOut className="w-5 h-5" />
<span>Sign Out</span> <span>Sign Out</span>
@ -335,56 +376,18 @@ export function Header() {
</> </>
) : ( ) : (
<> <>
<Link <div className="my-3 border-t border-border" />
href="/"
className="block px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
>
Domain
</Link>
<Link
href="/tld-pricing"
className="block px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
>
TLD
</Link>
<Link
href="/auctions"
className="block px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
>
Auctions
</Link>
<Link
href="/pricing"
className="block px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
>
Plans
</Link>
<Link <Link
href="/login" href="/login"
className="block px-4 py-3 text-body-sm text-foreground-muted className="block px-4 py-3 text-body-sm text-foreground-muted
hover:text-foreground hover:bg-background-secondary rounded-xl hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
transition-all duration-300"
onClick={() => setMobileMenuOpen(false)}
> >
Sign In Sign In
</Link> </Link>
<Link <Link
href="/register" href="/register"
className="block px-4 py-3 text-body-sm text-center bg-foreground text-background className="block px-4 py-3 text-body-sm text-center bg-foreground text-background
rounded-xl font-medium hover:bg-foreground/90 transition-all duration-300" rounded-xl font-medium hover:bg-foreground/90 transition-all duration-200"
onClick={() => setMobileMenuOpen(false)}
> >
Get Started Get Started
</Link> </Link>

View File

@ -0,0 +1,94 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, X, AlertCircle, Info } from 'lucide-react'
import clsx from 'clsx'
export type ToastType = 'success' | 'error' | 'info'
interface ToastProps {
message: string
type?: ToastType
duration?: number
onClose: () => void
}
export function Toast({ message, type = 'success', duration = 4000, onClose }: ToastProps) {
const [isVisible, setIsVisible] = useState(true)
const [isLeaving, setIsLeaving] = useState(false)
useEffect(() => {
const timer = setTimeout(() => {
setIsLeaving(true)
setTimeout(onClose, 300)
}, duration)
return () => clearTimeout(timer)
}, [duration, onClose])
const handleClose = () => {
setIsLeaving(true)
setTimeout(onClose, 300)
}
const Icon = type === 'success' ? Check : type === 'error' ? AlertCircle : Info
return (
<div
className={clsx(
"fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-xl shadow-2xl border transition-all duration-300",
isLeaving ? "translate-y-2 opacity-0" : "translate-y-0 opacity-100",
type === 'success' && "bg-accent/10 border-accent/20",
type === 'error' && "bg-danger/10 border-danger/20",
type === 'info' && "bg-foreground/5 border-border"
)}
>
<div className={clsx(
"w-7 h-7 rounded-lg flex items-center justify-center",
type === 'success' && "bg-accent/20",
type === 'error' && "bg-danger/20",
type === 'info' && "bg-foreground/10"
)}>
<Icon className={clsx(
"w-4 h-4",
type === 'success' && "text-accent",
type === 'error' && "text-danger",
type === 'info' && "text-foreground-muted"
)} />
</div>
<p className={clsx(
"text-body-sm",
type === 'success' && "text-accent",
type === 'error' && "text-danger",
type === 'info' && "text-foreground"
)}>{message}</p>
<button
onClick={handleClose}
className={clsx(
"ml-2 p-1 rounded hover:bg-foreground/5 transition-colors",
type === 'success' && "text-accent/70 hover:text-accent",
type === 'error' && "text-danger/70 hover:text-danger",
type === 'info' && "text-foreground-muted hover:text-foreground"
)}
>
<X className="w-4 h-4" />
</button>
</div>
)
}
// Hook for managing toasts
export function useToast() {
const [toast, setToast] = useState<{ message: string; type: ToastType } | null>(null)
const showToast = (message: string, type: ToastType = 'success') => {
setToast({ message, type })
}
const hideToast = () => {
setToast(null)
}
return { toast, showToast, hideToast }
}

View File

@ -85,8 +85,11 @@ class ApiClient {
}) })
if (!response.ok) { if (!response.ok) {
const error: ApiError = await response.json().catch(() => ({ detail: 'An error occurred' })) const errorData = await response.json().catch(() => ({ detail: 'An error occurred' }))
throw new Error(error.detail) const errorMessage = typeof errorData.detail === 'string'
? errorData.detail
: (errorData.message || JSON.stringify(errorData.detail) || 'An error occurred')
throw new Error(errorMessage)
} }
if (response.status === 204) { if (response.status === 204) {
@ -126,6 +129,8 @@ class ApiClient {
email: string email: string
name: string | null name: string | null
is_active: boolean is_active: boolean
is_admin: boolean
is_verified: boolean
created_at: string created_at: string
}>('/auth/me') }>('/auth/me')
} }
@ -173,6 +178,21 @@ class ApiClient {
}) })
} }
// OAuth
async getOAuthProviders() {
return this.request<{ google_enabled: boolean; github_enabled: boolean }>('/oauth/providers')
}
getGoogleLoginUrl(redirect?: string) {
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
return `${this.baseUrl}/oauth/google/login${params}`
}
getGitHubLoginUrl(redirect?: string) {
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
return `${this.baseUrl}/oauth/github/login${params}`
}
// Contact Form // Contact Form
async submitContact(name: string, email: string, subject: string, message: string) { async submitContact(name: string, email: string, subject: string, message: string) {
return this.request<{ message: string; success: boolean }>('/contact', { return this.request<{ message: string; success: boolean }>('/contact', {
@ -308,6 +328,7 @@ class ApiClient {
status: string status: string
domain_limit: number domain_limit: number
domains_used: number domains_used: number
portfolio_limit: number
check_frequency: string check_frequency: string
history_days: number history_days: number
features: { features: {
@ -315,8 +336,12 @@ class ApiClient {
priority_alerts: boolean priority_alerts: boolean
full_whois: boolean full_whois: boolean
expiration_tracking: boolean expiration_tracking: boolean
domain_valuation: boolean
market_insights: boolean
api_access: boolean api_access: boolean
webhooks: boolean webhooks: boolean
bulk_tools: boolean
seo_metrics: boolean
} }
started_at: string started_at: string
expires_at: string | null expires_at: string | null
@ -517,7 +542,7 @@ class ApiClient {
maxBid?: number, maxBid?: number,
endingSoon = false, endingSoon = false,
sortBy = 'ending', sortBy = 'ending',
limit = 20, limit = 100,
offset = 0 offset = 0
) { ) {
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -824,6 +849,15 @@ class AdminApiClient extends ApiClient {
return this.request<any>('/admin/tld-prices/stats') return this.request<any>('/admin/tld-prices/stats')
} }
// Auction Scraping
async triggerAuctionScrape() {
return this.request<any>('/auctions/admin/scrape', { method: 'POST' })
}
async getAuctionScrapeStatus() {
return this.request<any>('/auctions/admin/scrape-status')
}
// System // System
async getSystemHealth() { async getSystemHealth() {
return this.request<any>('/admin/system/health') return this.request<any>('/admin/system/health')
@ -832,6 +866,239 @@ class AdminApiClient extends ApiClient {
async makeUserAdmin(email: string) { async makeUserAdmin(email: string) {
return this.request<any>(`/admin/system/make-admin?email=${encodeURIComponent(email)}`, { method: 'POST' }) return this.request<any>(`/admin/system/make-admin?email=${encodeURIComponent(email)}`, { method: 'POST' })
} }
// Price Alerts
async getAdminPriceAlerts(limit = 100, offset = 0) {
return this.request<{
alerts: Array<{
id: number
tld: string
target_price: number | null
alert_type: string
created_at: string
user: { id: number; email: string; name: string | null }
}>
total: number
}>(`/admin/price-alerts?limit=${limit}&offset=${offset}`)
}
// Domain Health Check
async triggerDomainChecks() {
return this.request<{
message: string
domains_queued: number
started_at: string
}>('/admin/domains/check-all', { method: 'POST' })
}
// Email Test
async sendTestEmail() {
return this.request<{
message: string
sent_to: string
timestamp: string
}>('/admin/system/test-email', { method: 'POST' })
}
// Scheduler Status
async getSchedulerStatus() {
return this.request<{
scheduler_running: boolean
jobs: Array<{
id: string
name: string
next_run: string | null
trigger: string
}>
last_runs: {
tld_scrape: string | null
auction_scrape: string | null
domain_check: string | null
}
timestamp: string
}>('/admin/system/scheduler')
}
// User Export
async exportUsersCSV() {
return this.request<{
csv: string
count: number
exported_at: string
}>('/admin/users/export')
}
// Bulk Upgrade
async bulkUpgradeUsers(userIds: number[], tier: string) {
return this.request<{
message: string
tier: string
upgraded: Array<{ user_id: number; email: string }>
failed: Array<{ user_id: number; reason: string }>
total_upgraded: number
total_failed: number
}>('/admin/users/bulk-upgrade', {
method: 'POST',
body: JSON.stringify({ user_ids: userIds, tier }),
})
}
// Activity Log
async getActivityLog(limit = 50, offset = 0) {
return this.request<{
logs: Array<{
id: number
action: string
details: string
created_at: string
admin: { id: number; email: string; name: string | null }
}>
total: number
}>(`/admin/activity-log?limit=${limit}&offset=${offset}`)
}
// ============== Blog ==============
async getBlogPosts(limit = 10, offset = 0, category?: string, tag?: string) {
let url = `/blog/posts?limit=${limit}&offset=${offset}`
if (category) url += `&category=${encodeURIComponent(category)}`
if (tag) url += `&tag=${encodeURIComponent(tag)}`
return this.request<{
posts: Array<{
id: number
title: string
slug: string
excerpt: string | null
cover_image: string | null
category: string | null
tags: string[]
is_published: boolean
published_at: string | null
created_at: string
view_count: number
author: { id: number; name: string | null }
}>
total: number
limit: number
offset: number
}>(url)
}
async getBlogPost(slug: string) {
return this.request<{
id: number
title: string
slug: string
excerpt: string | null
content: string
cover_image: string | null
category: string | null
tags: string[]
meta_title: string | null
meta_description: string | null
is_published: boolean
published_at: string | null
created_at: string
updated_at: string
view_count: number
author: { id: number; name: string | null }
}>(`/blog/posts/${slug}`)
}
async getBlogCategories() {
return this.request<{
categories: Array<{ name: string; count: number }>
}>('/blog/posts/categories')
}
async getFeaturedPosts(limit = 3) {
return this.request<{
posts: Array<{
id: number
title: string
slug: string
excerpt: string | null
cover_image: string | null
category: string | null
published_at: string | null
}>
}>(`/blog/posts/featured?limit=${limit}`)
}
// Admin Blog
async getAdminBlogPosts(limit = 50, offset = 0, status?: 'published' | 'draft') {
let url = `/blog/admin/posts?limit=${limit}&offset=${offset}`
if (status) url += `&status_filter=${status}`
return this.request<{
posts: Array<{
id: number
title: string
slug: string
excerpt: string | null
cover_image: string | null
category: string | null
tags: string[]
is_published: boolean
published_at: string | null
created_at: string
view_count: number
author: { id: number; name: string | null }
}>
total: number
}>(url)
}
async createBlogPost(data: {
title: string
content: string
excerpt?: string
cover_image?: string
category?: string
tags?: string[]
meta_title?: string
meta_description?: string
is_published?: boolean
}) {
return this.request<any>('/blog/admin/posts', {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateBlogPost(postId: number, data: {
title?: string
content?: string
excerpt?: string
cover_image?: string
category?: string
tags?: string[]
meta_title?: string
meta_description?: string
is_published?: boolean
}) {
return this.request<any>(`/blog/admin/posts/${postId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
async deleteBlogPost(postId: number) {
return this.request<any>(`/blog/admin/posts/${postId}`, {
method: 'DELETE',
})
}
async publishBlogPost(postId: number) {
return this.request<any>(`/blog/admin/posts/${postId}/publish`, {
method: 'POST',
})
}
async unpublishBlogPost(postId: number) {
return this.request<any>(`/blog/admin/posts/${postId}/unpublish`, {
method: 'POST',
})
}
} }
export const api = new AdminApiClient() export const api = new AdminApiClient()

View File

@ -8,6 +8,8 @@ interface User {
id: number id: number
email: string email: string
name: string | null name: string | null
is_admin?: boolean
is_verified?: boolean
} }
interface Domain { interface Domain {
@ -27,6 +29,7 @@ interface Subscription {
tier_name?: string tier_name?: string
domain_limit: number domain_limit: number
domains_used: number domains_used: number
portfolio_limit?: number
check_frequency?: string check_frequency?: string
history_days?: number history_days?: number
features?: { features?: {
@ -34,8 +37,12 @@ interface Subscription {
priority_alerts: boolean priority_alerts: boolean
full_whois: boolean full_whois: boolean
expiration_tracking: boolean expiration_tracking: boolean
domain_valuation: boolean
market_insights: boolean
api_access: boolean api_access: boolean
webhooks: boolean webhooks: boolean
bulk_tools: boolean
seo_metrics: boolean
} }
} }
@ -165,6 +172,7 @@ export const useStore = create<AppState>((set, get) => ({
tier_name: sub.tier_name, tier_name: sub.tier_name,
domain_limit: sub.domain_limit, domain_limit: sub.domain_limit,
domains_used: sub.domains_used, domains_used: sub.domains_used,
portfolio_limit: sub.portfolio_limit,
check_frequency: sub.check_frequency, check_frequency: sub.check_frequency,
history_days: sub.history_days, history_days: sub.history_days,
features: sub.features, features: sub.features,