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
This commit is contained in:
@ -2,6 +2,7 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
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.check import router as check_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.contact import router as contact_router
|
||||
from app.api.price_alerts import router as price_alerts_router
|
||||
from app.api.blog import router as blog_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# Core API endpoints
|
||||
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(domains_router, prefix="/domains", tags=["Domain Management"])
|
||||
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)
|
||||
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
|
||||
|
||||
# Content
|
||||
api_router.include_router(blog_router, prefix="/blog", tags=["Blog"])
|
||||
|
||||
# Admin endpoints
|
||||
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
||||
|
||||
@ -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}")
|
||||
async def get_user(
|
||||
user_id: int,
|
||||
@ -574,3 +633,329 @@ async def make_user_admin(
|
||||
await db.commit()
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@ -125,20 +125,23 @@ def _format_time_remaining(end_time: datetime) -> str:
|
||||
|
||||
|
||||
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
|
||||
"""Get affiliate URL for a platform."""
|
||||
# Use the scraped auction URL directly
|
||||
if auction_url:
|
||||
"""Get affiliate URL for a platform - links directly to the auction page."""
|
||||
# Use the scraped auction URL directly if available
|
||||
if auction_url and auction_url.startswith("http"):
|
||||
return auction_url
|
||||
|
||||
# Fallback to platform search
|
||||
# Fallback to platform-specific search/listing pages
|
||||
platform_urls = {
|
||||
"GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
|
||||
"Sedo": f"https://sedo.com/search/?keyword={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}",
|
||||
"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(
|
||||
@ -476,6 +479,27 @@ async def trigger_scrape(
|
||||
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")
|
||||
async def get_smart_opportunities(
|
||||
current_user: User = Depends(get_current_user),
|
||||
@ -484,18 +508,19 @@ async def get_smart_opportunities(
|
||||
"""
|
||||
Smart Pounce Algorithm - Find the best auction opportunities.
|
||||
|
||||
Analyzes scraped auction data (NO mock data) to find:
|
||||
- Auctions ending soon with low bids
|
||||
- Domains with high estimated value vs current bid
|
||||
Analyzes auction data to find sweet spots:
|
||||
- Auctions ending soon (snipe potential)
|
||||
- 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
|
||||
query = (
|
||||
select(DomainAuction)
|
||||
.where(DomainAuction.is_active == True)
|
||||
.order_by(DomainAuction.end_time.asc())
|
||||
.limit(50)
|
||||
.limit(100)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
@ -504,12 +529,10 @@ async def get_smart_opportunities(
|
||||
if not auctions:
|
||||
return {
|
||||
"opportunities": [],
|
||||
"message": "No active auctions. Trigger a scrape to fetch latest data.",
|
||||
"valuation_method": "Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors.",
|
||||
"message": "No active auctions found.",
|
||||
"strategy_tips": [
|
||||
"🔄 Click 'Trigger Scrape' to fetch latest auction data",
|
||||
"🎯 Look for value_ratio > 1.0 (undervalued domains)",
|
||||
"⏰ Auctions ending soon often have best opportunities",
|
||||
"🔄 Check back soon for new auctions",
|
||||
"⏰ Best opportunities often appear as auctions near their end",
|
||||
],
|
||||
"generated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
@ -517,59 +540,96 @@ async def get_smart_opportunities(
|
||||
opportunities = []
|
||||
|
||||
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
|
||||
|
||||
estimated_value = valuation["estimated_value"]
|
||||
current_bid = auction.current_bid
|
||||
# Time urgency: Higher score for auctions ending soon
|
||||
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
|
||||
time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0)
|
||||
# Price factor: Reasonable price points are opportunities
|
||||
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({
|
||||
"auction": listing.model_dump(),
|
||||
"analysis": {
|
||||
"estimated_value": estimated_value,
|
||||
"current_bid": current_bid,
|
||||
"value_ratio": round(value_ratio, 2),
|
||||
"potential_profit": round(estimated_value - current_bid, 2),
|
||||
"opportunity_score": round(opportunity_score, 2),
|
||||
"time_factor": time_factor,
|
||||
"bid_factor": bid_factor,
|
||||
"recommendation": (
|
||||
"Strong buy" if opportunity_score > 5 else
|
||||
"Consider" if opportunity_score > 2 else
|
||||
"Monitor"
|
||||
),
|
||||
"reasoning": _get_opportunity_reasoning(
|
||||
value_ratio, hours_left, auction.num_bids, opportunity_score
|
||||
),
|
||||
"opportunity_score": opportunity_score,
|
||||
"time_score": time_score,
|
||||
"competition_score": competition_score,
|
||||
"price_score": price_score,
|
||||
"urgency": urgency,
|
||||
"competition": competition,
|
||||
"price_range": price_range,
|
||||
"recommendation": recommendation,
|
||||
"reasoning": f"{urgency} • {competition} • {price_range}",
|
||||
}
|
||||
})
|
||||
|
||||
# Sort by opportunity score
|
||||
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
|
||||
|
||||
return {
|
||||
"opportunities": opportunities[:10],
|
||||
"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."
|
||||
),
|
||||
"opportunities": opportunities[:15],
|
||||
"strategy_tips": [
|
||||
"🎯 Focus on value_ratio > 1.0 (estimated value exceeds current bid)",
|
||||
"⏰ Auctions ending in < 1 hour often have best snipe opportunities",
|
||||
"📉 Low bid count (< 10) might indicate overlooked gems",
|
||||
"💡 Premium TLDs (.com, .ai, .io) have highest aftermarket demand",
|
||||
"⏰ Auctions ending soon have snipe potential",
|
||||
"📉 Low bid count = overlooked opportunities",
|
||||
"💡 Set a max budget and stick to it",
|
||||
],
|
||||
"generated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@ -103,6 +103,19 @@ async def register(
|
||||
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
||||
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
||||
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()
|
||||
|
||||
# Generate verification token
|
||||
@ -134,6 +147,9 @@ async def login(user_data: UserLogin, db: Database):
|
||||
Note: Email verification is currently not enforced.
|
||||
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)
|
||||
|
||||
if not user:
|
||||
@ -143,6 +159,37 @@ async def login(user_data: UserLogin, db: Database):
|
||||
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
|
||||
require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"
|
||||
if require_verification and not user.is_verified:
|
||||
|
||||
422
backend/app/api/blog.py
Normal file
422
backend/app/api/blog.py
Normal 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
398
backend/app/api/oauth.py
Normal 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"
|
||||
)
|
||||
|
||||
@ -86,6 +86,7 @@ async def get_subscription(
|
||||
status=subscription.status.value,
|
||||
domain_limit=subscription.max_domains,
|
||||
domains_used=domains_used,
|
||||
portfolio_limit=config.get("portfolio_limit", 0),
|
||||
check_frequency=config["check_frequency"],
|
||||
history_days=config["history_days"],
|
||||
features=config["features"],
|
||||
|
||||
@ -7,6 +7,8 @@ from app.models.portfolio import PortfolioDomain, DomainValuation
|
||||
from app.models.auction import DomainAuction, AuctionScrapeLog
|
||||
from app.models.newsletter import NewsletterSubscriber
|
||||
from app.models.price_alert import PriceAlert
|
||||
from app.models.admin_log import AdminActivityLog
|
||||
from app.models.blog import BlogPost
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@ -21,4 +23,6 @@ __all__ = [
|
||||
"AuctionScrapeLog",
|
||||
"NewsletterSubscriber",
|
||||
"PriceAlert",
|
||||
"AdminActivityLog",
|
||||
"BlogPost",
|
||||
]
|
||||
|
||||
25
backend/app/models/admin_log.py
Normal file
25
backend/app/models/admin_log.py
Normal 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")
|
||||
|
||||
74
backend/app/models/blog.py
Normal file
74
backend/app/models/blog.py
Normal 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
|
||||
|
||||
@ -35,6 +35,11 @@ class User(Base):
|
||||
email_verification_token: Mapped[Optional[str]] = mapped_column(String(255), 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
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
@ -25,6 +25,7 @@ class UserResponse(BaseModel):
|
||||
name: Optional[str]
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
is_admin: bool = False
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
|
||||
@ -13,6 +13,7 @@ class SubscriptionResponse(BaseModel):
|
||||
status: str
|
||||
domain_limit: int
|
||||
domains_used: int = 0
|
||||
portfolio_limit: int = 0
|
||||
check_frequency: str
|
||||
history_days: int
|
||||
features: Dict[str, bool]
|
||||
|
||||
@ -5,10 +5,11 @@ Scrapes real auction data from various platforms WITHOUT using their APIs.
|
||||
Uses web scraping to get publicly available auction information.
|
||||
|
||||
Supported Platforms:
|
||||
- GoDaddy Auctions (auctions.godaddy.com)
|
||||
- Sedo (sedo.com/search/)
|
||||
- NameJet (namejet.com)
|
||||
- Afternic (afternic.com)
|
||||
- ExpiredDomains.net (aggregator for deleted domains)
|
||||
- GoDaddy Auctions (public listings via RSS/public pages)
|
||||
- Sedo (public marketplace)
|
||||
- NameJet (public auctions)
|
||||
- DropCatch (public auctions)
|
||||
|
||||
IMPORTANT:
|
||||
- Respects robots.txt
|
||||
@ -19,6 +20,7 @@ IMPORTANT:
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any
|
||||
from urllib.parse import urljoin, quote
|
||||
@ -37,7 +39,7 @@ RATE_LIMITS = {
|
||||
"GoDaddy": 10,
|
||||
"Sedo": 10,
|
||||
"NameJet": 10,
|
||||
"Afternic": 10,
|
||||
"DropCatch": 10,
|
||||
"ExpiredDomains": 5,
|
||||
}
|
||||
|
||||
@ -103,6 +105,10 @@ class AuctionScraperService:
|
||||
# Scrape each platform
|
||||
scrapers = [
|
||||
("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:
|
||||
@ -121,12 +127,35 @@ class AuctionScraperService:
|
||||
|
||||
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]:
|
||||
"""
|
||||
Scrape ExpiredDomains.net for auction listings.
|
||||
|
||||
This site aggregates auctions from multiple sources.
|
||||
Public page: https://www.expireddomains.net/domain-name-search/
|
||||
This site aggregates expired/deleted domains from various TLDs.
|
||||
"""
|
||||
platform = "ExpiredDomains"
|
||||
result = {"found": 0, "new": 0, "updated": 0}
|
||||
@ -139,28 +168,25 @@ class AuctionScraperService:
|
||||
await self._rate_limit(platform)
|
||||
client = await self._get_client()
|
||||
|
||||
# ExpiredDomains has a public search page
|
||||
# We'll scrape their "deleted domains" which shows domains becoming available
|
||||
# Scrape deleted domains page
|
||||
url = "https://www.expireddomains.net/deleted-domains/"
|
||||
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}")
|
||||
|
||||
soup = BeautifulSoup(response.text, "lxml")
|
||||
|
||||
# Find domain listings in the table
|
||||
domain_rows = soup.select("table.base1 tbody tr")
|
||||
|
||||
auctions = []
|
||||
for row in domain_rows[:50]: # Limit to 50 per scrape
|
||||
# TLD-based pricing
|
||||
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:
|
||||
cols = row.find_all("td")
|
||||
if len(cols) < 3:
|
||||
continue
|
||||
|
||||
# Extract domain from first column
|
||||
domain_link = cols[0].find("a")
|
||||
if not domain_link:
|
||||
continue
|
||||
@ -171,15 +197,12 @@ class AuctionScraperService:
|
||||
|
||||
domain = domain_text.lower()
|
||||
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)
|
||||
|
||||
auction_data = {
|
||||
"domain": domain,
|
||||
"tld": tld,
|
||||
"platform": "ExpiredDomains",
|
||||
"platform": platform,
|
||||
"platform_auction_id": None,
|
||||
"auction_url": f"https://www.expireddomains.net/domain-name-search/?q={quote(domain)}",
|
||||
"current_bid": float(estimated_price),
|
||||
@ -199,42 +222,15 @@ class AuctionScraperService:
|
||||
"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:
|
||||
logger.debug(f"Error parsing row: {e}")
|
||||
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()
|
||||
|
||||
# Update log
|
||||
log.completed_at = datetime.utcnow()
|
||||
log.status = "success"
|
||||
log.auctions_found = result["found"]
|
||||
@ -242,15 +238,421 @@ class AuctionScraperService:
|
||||
log.auctions_updated = result["updated"]
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"ExpiredDomains scrape complete: {result}")
|
||||
|
||||
except Exception as e:
|
||||
log.completed_at = datetime.utcnow()
|
||||
log.status = "failed"
|
||||
log.error_message = str(e)
|
||||
await db.commit()
|
||||
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
|
||||
|
||||
@ -284,6 +686,99 @@ class AuctionScraperService:
|
||||
|
||||
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(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
@ -350,4 +845,3 @@ class AuctionScraperService:
|
||||
|
||||
# Global instance
|
||||
auction_scraper = AuctionScraperService()
|
||||
|
||||
|
||||
@ -619,3 +619,72 @@ class DomainChecker:
|
||||
|
||||
# Singleton instance
|
||||
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,
|
||||
}
|
||||
|
||||
36
backend/scripts/seed_auctions.py
Normal file
36
backend/scripts/seed_auctions.py
Normal 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
@ -1,262 +1,126 @@
|
||||
'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 { Footer } from '@/components/Footer'
|
||||
import { ArrowLeft, Calendar, Clock, User, Share2, Twitter, Linkedin, Link as LinkIcon, BookOpen, ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
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
|
||||
const blogPosts: Record<string, {
|
||||
interface BlogPost {
|
||||
id: number
|
||||
title: string
|
||||
excerpt: string
|
||||
slug: string
|
||||
excerpt: string | null
|
||||
content: string
|
||||
category: string
|
||||
date: string
|
||||
readTime: string
|
||||
author: string
|
||||
}> = {
|
||||
'complete-guide-domain-investing-2025': {
|
||||
title: 'The Complete Guide to Domain Investing in 2025',
|
||||
excerpt: 'Everything you need to know about finding, evaluating, and acquiring valuable domains in today\'s market.',
|
||||
category: 'Guide',
|
||||
date: 'Dec 5, 2025',
|
||||
readTime: '12 min read',
|
||||
author: 'pounce Team',
|
||||
content: `
|
||||
# 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!
|
||||
`,
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const slug = params.slug as string
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const [post, setPost] = useState<BlogPost | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const post = blogPosts[slug]
|
||||
useEffect(() => {
|
||||
if (slug) {
|
||||
loadPost()
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
if (!post) {
|
||||
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 (
|
||||
<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 />
|
||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h1 className="text-heading-lg font-medium text-foreground mb-4">Post Not Found</h1>
|
||||
<p className="text-body text-foreground-muted mb-8">
|
||||
The blog post you're looking for doesn't exist or has been moved.
|
||||
<main className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="text-center max-w-md">
|
||||
<BookOpen className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||
<h1 className="text-2xl font-display text-foreground mb-4">Post Not Found</h1>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
The blog post you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<Link
|
||||
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" />
|
||||
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 (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{/* Back link */}
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
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" />
|
||||
Back to Blog
|
||||
<span className="text-sm font-medium">Back to Blog</span>
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-10 animate-fade-in">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="px-2.5 py-1 bg-accent/10 text-accent text-ui-xs font-medium rounded-full">
|
||||
<header className="mb-12">
|
||||
{post.category && (
|
||||
<span className="inline-block px-3 py-1 bg-accent/10 text-accent text-sm font-medium rounded-full mb-6">
|
||||
{post.category}
|
||||
</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}
|
||||
</h1>
|
||||
|
||||
<p className="text-body-lg text-foreground-muted mb-6">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-body-sm text-foreground-muted">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<User className="w-4 h-4" />
|
||||
{post.author}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm text-foreground-muted">
|
||||
{post.author.name && (
|
||||
<span className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
{post.author.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{post.date}
|
||||
{post.published_at ? formatDate(post.published_at) : formatDate(post.created_at)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
{post.readTime}
|
||||
{estimateReadTime(post.content)}
|
||||
</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>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="prose prose-invert prose-lg max-w-none animate-slide-up">
|
||||
{post.content.split('\n').map((line, i) => {
|
||||
if (line.startsWith('# ')) {
|
||||
return <h1 key={i} className="text-heading-lg font-display text-foreground mt-8 mb-4">{line.slice(2)}</h1>
|
||||
}
|
||||
if (line.startsWith('## ')) {
|
||||
return <h2 key={i} className="text-heading-md font-medium text-foreground mt-8 mb-4">{line.slice(3)}</h2>
|
||||
}
|
||||
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>
|
||||
{/* Cover Image */}
|
||||
{post.cover_image && (
|
||||
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden mb-12 bg-background-secondary">
|
||||
<Image
|
||||
src={post.cover_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related posts */}
|
||||
<div className="mt-12 pt-8 border-t border-border">
|
||||
<h3 className="text-heading-sm font-medium text-foreground mb-6">Continue Reading</h3>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
{Object.entries(blogPosts)
|
||||
.filter(([s]) => s !== slug)
|
||||
.slice(0, 2)
|
||||
.map(([postSlug, relatedPost]) => (
|
||||
{/* Content */}
|
||||
<div
|
||||
className="prose prose-invert prose-lg max-w-none
|
||||
prose-headings:font-display prose-headings:tracking-tight
|
||||
prose-h2:text-2xl prose-h2:mt-12 prose-h2:mb-4
|
||||
prose-h3:text-xl prose-h3:mt-8 prose-h3:mb-3
|
||||
prose-p:text-foreground-muted prose-p:leading-relaxed
|
||||
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
|
||||
key={postSlug}
|
||||
href={`/blog/${postSlug}`}
|
||||
className="p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all group"
|
||||
key={tag}
|
||||
href={`/blog?tag=${encodeURIComponent(tag)}`}
|
||||
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>
|
||||
<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>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-12 p-8 bg-background-secondary/50 border border-border rounded-2xl text-center">
|
||||
<BookOpen className="w-8 h-8 text-accent mx-auto mb-4" />
|
||||
<h3 className="text-heading-sm font-medium text-foreground mb-2">
|
||||
Ready to start investing?
|
||||
<div className="mt-16 p-8 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center">
|
||||
<h3 className="text-xl font-display text-foreground mb-3">
|
||||
Ready to start hunting domains?
|
||||
</h3>
|
||||
<p className="text-body text-foreground-muted mb-6">
|
||||
Track domains, monitor prices, and build your portfolio with pounce.
|
||||
<p className="text-foreground-muted mb-6">
|
||||
Join thousands of domain hunters using pounce to find and secure premium domains.
|
||||
</p>
|
||||
<Link
|
||||
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
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
@ -446,4 +265,3 @@ export default function BlogPostPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,207 +1,260 @@
|
||||
'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 { Footer } from '@/components/Footer'
|
||||
import { api } from '@/lib/api'
|
||||
import { BookOpen, Calendar, Clock, ArrowRight, TrendingUp, Shield, Zap, Loader2, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Clock,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
Tag,
|
||||
Loader2,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const featuredPost = {
|
||||
title: 'Domain Investing 2025: The No-BS Guide',
|
||||
excerpt: 'Find. Evaluate. Acquire. Everything you need to hunt valuable domains in today\'s market.',
|
||||
category: 'Guide',
|
||||
date: 'Dec 5, 2025',
|
||||
readTime: '12 min read',
|
||||
slug: 'complete-guide-domain-investing-2025',
|
||||
interface BlogPost {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const posts = [
|
||||
{
|
||||
title: 'TLD Pricing: Reading the Market',
|
||||
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,
|
||||
},
|
||||
]
|
||||
interface Category {
|
||||
name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export default function BlogPage() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [subscribeState, setSubscribeState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [posts, setPosts] = useState<BlogPost[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const handleSubscribe = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
setSubscribeState('error')
|
||||
setErrorMessage('Please enter a valid email address')
|
||||
return
|
||||
}
|
||||
|
||||
setSubscribeState('loading')
|
||||
setErrorMessage('')
|
||||
|
||||
useEffect(() => {
|
||||
loadBlogData()
|
||||
}, [selectedCategory])
|
||||
|
||||
const loadBlogData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.subscribeNewsletter(email)
|
||||
setSubscribeState('success')
|
||||
setEmail('')
|
||||
|
||||
// Reset after 5 seconds
|
||||
setTimeout(() => {
|
||||
setSubscribeState('idle')
|
||||
}, 5000)
|
||||
} catch (err: any) {
|
||||
setSubscribeState('error')
|
||||
setErrorMessage(err.message || 'Failed to subscribe. Please try again.')
|
||||
const [postsData, categoriesData] = await Promise.all([
|
||||
api.getBlogPosts(12, 0, selectedCategory || undefined),
|
||||
api.getBlogCategories(),
|
||||
])
|
||||
setPosts(postsData.posts)
|
||||
setTotal(postsData.total)
|
||||
setCategories(categoriesData.categories)
|
||||
} catch (error) {
|
||||
console.error('Failed to load blog:', error)
|
||||
} finally {
|
||||
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 (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<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>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-12 sm: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">
|
||||
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-16 animate-fade-in">
|
||||
<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" />
|
||||
<span className="text-ui-sm text-foreground-muted">Domain Insights</span>
|
||||
<span className="text-sm font-medium text-accent">Domain Intelligence Blog</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">
|
||||
The Hunt Report
|
||||
<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">
|
||||
Insights & Strategies.
|
||||
</h1>
|
||||
<p className="text-body-lg text-foreground-muted max-w-xl mx-auto">
|
||||
Market intel. Strategies. Plays that work. For domain hunters.
|
||||
<p className="text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||
Domain hunting tips, market analysis, and expert strategies to help you secure premium domains.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Featured Post */}
|
||||
<Link
|
||||
href={`/blog/${featuredPost.slug}`}
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="px-2.5 py-1 bg-accent/10 text-accent text-ui-xs font-medium rounded-full">
|
||||
Featured
|
||||
</span>
|
||||
<span className="text-ui-xs text-foreground-subtle">{featuredPost.category}</span>
|
||||
{/* Categories */}
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 mb-12">
|
||||
<button
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
className={clsx(
|
||||
"px-4 py-2 text-sm font-medium rounded-xl transition-all",
|
||||
!selectedCategory
|
||||
? "bg-accent text-background"
|
||||
: "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>
|
||||
<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 */}
|
||||
<div className="grid sm:grid-cols-3 gap-4 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
||||
{posts.map((post, i) => (
|
||||
<Link
|
||||
key={post.title}
|
||||
href={`/blog/${post.title.toLowerCase().replace(/\s+/g, '-')}`}
|
||||
className="p-5 bg-background-secondary/30 border border-border rounded-xl hover:border-border-hover transition-all group"
|
||||
>
|
||||
<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">
|
||||
<post.icon className="w-4 h-4 text-foreground-muted group-hover:text-accent transition-colors" />
|
||||
</div>
|
||||
<span className="text-ui-xs text-accent mb-2 block">{post.category}</span>
|
||||
<h3 className="text-body font-medium text-foreground mb-2 group-hover:text-accent transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-body-sm text-foreground-muted mb-4 line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-ui-xs text-foreground-subtle">
|
||||
<span>{post.date}</span>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||
</div>
|
||||
) : posts.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<FileText className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||
<h2 className="text-2xl font-display text-foreground mb-3">No posts yet</h2>
|
||||
<p className="text-foreground-muted">
|
||||
Check back soon for domain hunting insights and strategies.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
{posts.map((post, index) => (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/blog/${post.slug}`}
|
||||
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"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{/* Cover Image */}
|
||||
{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 */}
|
||||
<div className="mt-16 p-8 sm:p-10 bg-background-secondary/50 border border-border rounded-2xl text-center animate-slide-up">
|
||||
<h3 className="text-heading-sm font-medium text-foreground mb-3">
|
||||
Stay Updated
|
||||
</h3>
|
||||
<p className="text-body text-foreground-muted mb-6 max-w-md mx-auto">
|
||||
Get the latest domain insights and market analysis delivered to your inbox.
|
||||
</p>
|
||||
|
||||
{subscribeState === 'success' ? (
|
||||
<div className="flex items-center justify-center gap-3 text-accent">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="text-body font-medium">Thanks for subscribing! Check your email.</span>
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-6">
|
||||
<h2 className="text-lg font-display text-foreground mb-3 group-hover:text-accent transition-colors line-clamp-2">
|
||||
{post.title}
|
||||
</h2>
|
||||
{post.excerpt && (
|
||||
<p className="text-body-sm text-foreground-muted mb-4 line-clamp-3">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex items-center gap-4 text-ui-xs text-foreground-subtle mt-auto">
|
||||
<span className="flex items-center gap-1">
|
||||
<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>
|
||||
) : (
|
||||
<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 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4 text-danger text-body-sm">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
{posts.length < total && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
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>
|
||||
</main>
|
||||
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useStore } from '@/lib/store'
|
||||
import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Toast, useToast } from '@/components/Toast'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
@ -42,8 +43,9 @@ interface DomainHistory {
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
function DashboardContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
@ -55,11 +57,21 @@ export default function DashboardPage() {
|
||||
refreshDomain,
|
||||
} = useStore()
|
||||
|
||||
const { toast, showToast, hideToast } = useToast()
|
||||
const [activeTab, setActiveTab] = useState<TabType>('watchlist')
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [refreshingId, setRefreshingId] = useState<number | 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 [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null)
|
||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||
@ -150,6 +162,7 @@ export default function DashboardPage() {
|
||||
setError(null)
|
||||
try {
|
||||
await addDomain(newDomain)
|
||||
showToast(`Added ${newDomain} to watchlist`, 'success')
|
||||
setNewDomain('')
|
||||
} catch (err) {
|
||||
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: '' })
|
||||
setShowAddPortfolioModal(false)
|
||||
showToast(`Added ${portfolioForm.domain} to portfolio`, 'success')
|
||||
loadPortfolio()
|
||||
} catch (err) {
|
||||
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
|
||||
try {
|
||||
await api.deletePortfolioDomain(id)
|
||||
showToast('Domain removed from portfolio', 'success')
|
||||
loadPortfolio()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete domain')
|
||||
@ -290,6 +305,7 @@ export default function DashboardPage() {
|
||||
})
|
||||
setShowEditPortfolioModal(false)
|
||||
setEditingPortfolioDomain(null)
|
||||
showToast('Domain updated', 'success')
|
||||
loadPortfolio()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update domain')
|
||||
@ -311,7 +327,9 @@ export default function DashboardPage() {
|
||||
try {
|
||||
await api.markDomainSold(sellingDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
|
||||
setShowSellModal(false)
|
||||
const soldDomainName = sellingDomain.domain
|
||||
setSellingDomain(null)
|
||||
showToast(`Marked ${soldDomainName} as sold 🎉`, 'success')
|
||||
loadPortfolio()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to mark domain as sold')
|
||||
@ -363,38 +381,58 @@ export default function DashboardPage() {
|
||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||
const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise' || tierName === 'Trader' || 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 (
|
||||
<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 />
|
||||
|
||||
<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">
|
||||
{/* 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>
|
||||
<h1 className="font-display text-3xl sm:text-4xl tracking-tight text-foreground">
|
||||
Command Center
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Command Center</span>
|
||||
<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>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={clsx(
|
||||
"flex items-center gap-1.5 text-ui-sm px-3 py-1.5 rounded-lg border",
|
||||
isEnterprise ? "bg-accent/10 border-accent/20 text-accent" : "bg-background-secondary border-border text-foreground-muted"
|
||||
"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/50 border-border text-foreground-muted"
|
||||
)}>
|
||||
{isEnterprise && <Crown className="w-3.5 h-3.5" />}
|
||||
{isEnterprise && <Crown className="w-4 h-4" />}
|
||||
{tierName}
|
||||
</span>
|
||||
{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" />
|
||||
Billing
|
||||
</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" />
|
||||
Upgrade
|
||||
</Link>
|
||||
@ -402,14 +440,14 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<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">
|
||||
{/* Tabs - Landing Page Style */}
|
||||
<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
|
||||
onClick={() => setActiveTab('watchlist')}
|
||||
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'
|
||||
? "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"
|
||||
)}
|
||||
>
|
||||
@ -417,29 +455,40 @@ export default function DashboardPage() {
|
||||
Watchlist
|
||||
{domains.length > 0 && (
|
||||
<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"
|
||||
)}>{domains.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('portfolio')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 px-5 py-2.5 text-ui font-medium rounded-xl transition-all duration-200",
|
||||
activeTab === 'portfolio'
|
||||
? "bg-foreground text-background shadow-lg"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<Briefcase className="w-4 h-4" />
|
||||
Portfolio
|
||||
{portfolio.length > 0 && (
|
||||
<span className={clsx(
|
||||
"text-ui-xs px-2 py-0.5 rounded-md",
|
||||
activeTab === 'portfolio' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{portfolio.length}</span>
|
||||
)}
|
||||
</button>
|
||||
{hasPortfolio ? (
|
||||
<button
|
||||
onClick={() => setActiveTab('portfolio')}
|
||||
className={clsx(
|
||||
"flex items-center gap-2.5 px-6 py-3 text-sm font-medium rounded-xl transition-all duration-300",
|
||||
activeTab === 'portfolio'
|
||||
? "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
|
||||
{portfolio.length > 0 && (
|
||||
<span className={clsx(
|
||||
"text-xs px-2 py-0.5 rounded-lg",
|
||||
activeTab === 'portfolio' ? "bg-background/20" : "bg-foreground/10"
|
||||
)}>{portfolio.length}</span>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
@ -452,25 +501,51 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Watchlist Tab */}
|
||||
{activeTab === 'watchlist' && (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors">
|
||||
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Tracked</p>
|
||||
<p className="text-3xl font-display text-foreground">{domains.length}</p>
|
||||
<div className="space-y-8">
|
||||
{/* Stats Row - Landing Page Style */}
|
||||
<div className={clsx("grid gap-5", hasExpirationTracking ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3")}>
|
||||
<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-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 className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors">
|
||||
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Available</p>
|
||||
<p className="text-3xl font-display text-foreground">{availableCount}</p>
|
||||
<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-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 className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors">
|
||||
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Monitoring</p>
|
||||
<p className="text-3xl font-display text-foreground">{domains.filter(d => d.notify_on_available).length}</p>
|
||||
</div>
|
||||
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors">
|
||||
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Expiring</p>
|
||||
<p className="text-3xl font-display text-foreground">{expiringCount}</p>
|
||||
<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-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">
|
||||
<Bell 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">Monitoring</p>
|
||||
<p className="text-4xl font-display text-foreground">{domains.filter(d => d.notify_on_available).length}</p>
|
||||
</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>
|
||||
|
||||
{/* Limit Warning */}
|
||||
@ -525,7 +600,9 @@ export default function DashboardPage() {
|
||||
<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 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-right text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Actions</th>
|
||||
</tr>
|
||||
@ -562,16 +639,18 @@ export default function DashboardPage() {
|
||||
{domain.is_available ? 'Available' : isMonitoring ? 'Monitoring' : 'Registered'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 hidden lg:table-cell">
|
||||
{exp ? (
|
||||
<span className={clsx(
|
||||
"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>
|
||||
) : <span className="text-foreground-subtle/50">—</span>}
|
||||
</td>
|
||||
{hasExpirationTracking && (
|
||||
<td className="px-5 py-4 hidden lg:table-cell">
|
||||
{exp ? (
|
||||
<span className={clsx(
|
||||
"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>
|
||||
) : <span className="text-foreground-subtle/50">—</span>}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-5 py-4 hidden md:table-cell">
|
||||
<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)}
|
||||
@ -579,14 +658,20 @@ export default function DashboardPage() {
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<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">
|
||||
<History className="w-4 h-4" />
|
||||
</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">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
{hasDomainValuation ? (
|
||||
<button onClick={() => handleGetValuation(domain.name)} className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/10 rounded-lg transition-all" title="Valuation">
|
||||
<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
|
||||
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||
disabled={togglingNotifyId === domain.id}
|
||||
@ -994,6 +1079,27 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={hideToast}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
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() {
|
||||
return (
|
||||
<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 searchParams = useSearchParams()
|
||||
const { login } = useStore()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
@ -29,6 +51,26 @@ export default function LoginPage() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
@ -37,14 +79,183 @@ export default function LoginPage() {
|
||||
|
||||
try {
|
||||
await login(email, password)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed')
|
||||
// Redirect to intended destination or dashboard
|
||||
router.push(redirectTo)
|
||||
} 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 {
|
||||
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'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 (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12 relative">
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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't have an account?{' '}
|
||||
<Link href="/register" className="text-foreground hover:text-accent transition-colors duration-300">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Suspense fallback={
|
||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
67
frontend/src/app/oauth/callback/page.tsx
Normal file
67
frontend/src/app/oauth/callback/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -7,85 +7,36 @@ import { Footer } from '@/components/Footer'
|
||||
import { DomainChecker } from '@/components/DomainChecker'
|
||||
import { useStore } from '@/lib/store'
|
||||
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 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 {
|
||||
tld: string
|
||||
reason: string
|
||||
current_price: number
|
||||
price_change: number // API returns price_change, not price_change_percent
|
||||
price_change: number
|
||||
}
|
||||
|
||||
// Shimmer component for locked content
|
||||
function ShimmerBlock({ className }: { className?: string }) {
|
||||
// Shimmer for loading states
|
||||
function Shimmer({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"relative overflow-hidden rounded bg-background-tertiary",
|
||||
"relative overflow-hidden rounded bg-foreground/5",
|
||||
className
|
||||
)}>
|
||||
<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() {
|
||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||
const [tldData, setTldData] = useState<TldData[]>([])
|
||||
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
||||
const [loadingTlds, setLoadingTlds] = useState(true)
|
||||
|
||||
@ -106,11 +82,7 @@ export default function HomePage() {
|
||||
|
||||
const fetchTldData = async () => {
|
||||
try {
|
||||
const [overview, trending] = await Promise.all([
|
||||
api.getTldOverview(8),
|
||||
api.getTrendingTlds()
|
||||
])
|
||||
setTldData(overview.tlds)
|
||||
const trending = await api.getTrendingTlds()
|
||||
setTrendingTlds(trending.trending.slice(0, 4))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch TLD data:', error)
|
||||
@ -121,8 +93,8 @@ export default function HomePage() {
|
||||
|
||||
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 className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -132,293 +104,339 @@ export default function HomePage() {
|
||||
if (priceChange < 0) return <TrendingDown 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 (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Ambient background glow */}
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<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>
|
||||
|
||||
<Header />
|
||||
|
||||
{/* 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">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
{/* Puma Logo - Compact & Elegant */}
|
||||
<div className="flex justify-center mb-6 sm:mb-8 animate-fade-in">
|
||||
<Image
|
||||
src="/pounce-puma.png"
|
||||
alt="pounce"
|
||||
width={300}
|
||||
height={210}
|
||||
className="w-32 h-auto sm:w-40 md:w-48 object-contain drop-shadow-[0_0_40px_rgba(16,185,129,0.25)]"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Headline - RESPONSIVE */}
|
||||
<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">
|
||||
<span className="block text-foreground">Others wait.</span>
|
||||
<span className="block text-foreground-muted">You pounce.</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline - RESPONSIVE */}
|
||||
<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">
|
||||
Domain intelligence for the decisive. Track any domain.
|
||||
Know the moment it drops. Move before anyone else.
|
||||
</p>
|
||||
|
||||
{/* Domain Checker */}
|
||||
<div className="animate-slide-up delay-150">
|
||||
<DomainChecker />
|
||||
<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">
|
||||
<div className="text-center max-w-5xl mx-auto">
|
||||
{/* Puma Logo */}
|
||||
<div className="flex justify-center mb-8 sm:mb-10 animate-fade-in">
|
||||
<div className="relative">
|
||||
<Image
|
||||
src="/pounce-puma.png"
|
||||
alt="pounce"
|
||||
width={400}
|
||||
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)]"
|
||||
priority
|
||||
/>
|
||||
{/* Glow ring */}
|
||||
<div className="absolute inset-0 -z-10 bg-accent/20 blur-3xl rounded-full scale-150" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Headline - MASSIVE */}
|
||||
<h1 className="animate-slide-up">
|
||||
<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">
|
||||
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/40 mt-2">
|
||||
You pounce.
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
{/* Subheadline */}
|
||||
<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.
|
||||
Know the moment it drops. Move before anyone else.
|
||||
</p>
|
||||
|
||||
{/* Domain Checker */}
|
||||
<div className="mt-10 sm:mt-14 md:mt-16 animate-slide-up delay-200">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* TLD Price Intelligence Section */}
|
||||
<section className="relative py-16 sm:py-20 md:py-24 px-4 sm:px-6 border-t border-border-subtle">
|
||||
{/* Trending TLDs Section */}
|
||||
<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 mb-10 sm:mb-12">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-accent-muted border border-accent/20 rounded-full mb-5">
|
||||
<TrendingUp className="w-4 h-4 text-accent" />
|
||||
<span className="text-ui-sm text-accent">Market Intel</span>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-10 sm:mb-14">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-accent/10 border border-accent/20 rounded-full mb-5">
|
||||
<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>
|
||||
<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">
|
||||
886 TLDs. Tracked Daily.
|
||||
</h2>
|
||||
<p className="text-body-sm sm:text-body text-foreground-muted max-w-lg mx-auto">
|
||||
See price movements. Spot opportunities. Act fast.
|
||||
</p>
|
||||
<Link
|
||||
href="/tld-pricing"
|
||||
className="group inline-flex items-center gap-2 text-sm font-medium text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
Explore all TLDs
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Trending TLDs - Card Grid */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<TrendingUp className="w-4 h-4 text-accent" />
|
||||
<span className="text-ui font-medium text-foreground">Trending Now</span>
|
||||
{/* TLD Cards */}
|
||||
{loadingTlds ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<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>
|
||||
|
||||
{loadingTlds ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<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>
|
||||
) : (
|
||||
<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" />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="font-mono text-2xl sm:text-3xl font-medium text-foreground">.{item.tld}</span>
|
||||
<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
|
||||
? "text-[#f97316] bg-[#f9731615]"
|
||||
? "text-orange-400 bg-orange-400/10"
|
||||
: (item.price_change ?? 0) < 0
|
||||
? "text-accent bg-accent-muted"
|
||||
: "text-foreground-muted bg-background-tertiary"
|
||||
? "text-accent bg-accent/10"
|
||||
: "text-foreground-muted bg-foreground/5"
|
||||
)}>
|
||||
{getTrendIcon(item.price_change ?? 0)}
|
||||
{(item.price_change ?? 0) > 0 ? '+' : ''}{(item.price_change ?? 0).toFixed(1)}%
|
||||
</span>
|
||||
</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">
|
||||
{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>
|
||||
</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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* View All Link */}
|
||||
<div className="mt-6 text-center">
|
||||
{/* Features Section */}
|
||||
<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
|
||||
href="/tld-pricing"
|
||||
className="inline-flex items-center gap-2 text-body-sm font-medium text-accent hover:text-accent-hover transition-colors"
|
||||
href="/pricing"
|
||||
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" />
|
||||
</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>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-7xl 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">How It Works</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">
|
||||
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'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
|
||||
{/* Final CTA */}
|
||||
<section className="relative py-24 sm:py-32 px-4 sm:px-6">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="font-display text-4xl sm:text-5xl md:text-6xl lg:text-7xl tracking-[-0.03em] text-foreground mb-6">
|
||||
Ready to hunt?
|
||||
</h2>
|
||||
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted mb-8 sm:mb-10">
|
||||
Create a free account and track up to 3 domains.
|
||||
No credit card required.
|
||||
<p className="text-xl text-foreground-muted mb-10 max-w-lg mx-auto">
|
||||
Track your first domain in under a minute. No credit card required.
|
||||
</p>
|
||||
<Link
|
||||
href="/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"
|
||||
href={isAuthenticated ? "/dashboard" : "/register"}
|
||||
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
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
{isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
|
||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { useStore } from '@/lib/store'
|
||||
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 clsx from 'clsx'
|
||||
|
||||
@ -19,11 +19,10 @@ const tiers = [
|
||||
period: '',
|
||||
description: 'Test the waters. Zero risk.',
|
||||
features: [
|
||||
{ text: '5 domains to track', highlight: false },
|
||||
{ text: 'Daily availability scans', highlight: false },
|
||||
{ text: 'Email alerts', highlight: false },
|
||||
{ text: 'Basic search', highlight: false },
|
||||
{ text: 'TLD price overview', highlight: false },
|
||||
{ text: '5 domains to track', highlight: false, available: true },
|
||||
{ text: 'Daily availability scans', highlight: false, available: true },
|
||||
{ text: 'Email alerts', highlight: false, available: true },
|
||||
{ text: 'TLD price overview', highlight: false, available: true },
|
||||
],
|
||||
cta: 'Hunt Free',
|
||||
highlighted: false,
|
||||
@ -38,13 +37,14 @@ const tiers = [
|
||||
period: '/mo',
|
||||
description: 'Hunt with precision. Daily intel.',
|
||||
features: [
|
||||
{ text: '50 domains to track', highlight: true },
|
||||
{ text: 'Hourly scans', highlight: true },
|
||||
{ text: 'SMS & Telegram alerts', highlight: true },
|
||||
{ text: 'Full market data', highlight: false },
|
||||
{ text: 'Domain valuation', highlight: true },
|
||||
{ text: 'Portfolio (25 domains)', highlight: true },
|
||||
{ text: '90-day price history', highlight: false },
|
||||
{ text: '50 domains to track', highlight: true, available: true },
|
||||
{ text: 'Hourly scans', highlight: true, available: true },
|
||||
{ text: 'Email alerts', highlight: false, available: true },
|
||||
{ text: 'Full TLD market data', highlight: false, available: true },
|
||||
{ text: 'Domain valuation', highlight: true, available: true },
|
||||
{ text: 'Portfolio (25 domains)', highlight: true, available: true },
|
||||
{ text: '90-day price history', highlight: false, available: true },
|
||||
{ text: 'Expiry tracking', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Start Trading',
|
||||
highlighted: true,
|
||||
@ -59,14 +59,12 @@ const tiers = [
|
||||
period: '/mo',
|
||||
description: 'Dominate the market. No limits.',
|
||||
features: [
|
||||
{ text: '500 domains to track', highlight: true },
|
||||
{ text: 'Real-time scans (10 min)', highlight: true },
|
||||
{ text: 'Priority alerts + Webhooks', highlight: true },
|
||||
{ text: 'Full REST API', highlight: true },
|
||||
{ text: 'SEO metrics (DA/PA)', highlight: true },
|
||||
{ text: 'Unlimited portfolio', highlight: true },
|
||||
{ text: 'Bulk import/export', highlight: true },
|
||||
{ text: 'White-label reports', highlight: false },
|
||||
{ text: '500 domains to track', highlight: true, available: true },
|
||||
{ text: 'Real-time scans (10 min)', highlight: true, available: true },
|
||||
{ text: 'Priority email alerts', highlight: false, available: true },
|
||||
{ text: 'Unlimited portfolio', highlight: true, available: true },
|
||||
{ text: 'Full price history', highlight: true, available: true },
|
||||
{ text: 'Advanced valuation', highlight: true, available: true },
|
||||
],
|
||||
cta: 'Go Tycoon',
|
||||
highlighted: false,
|
||||
@ -80,10 +78,8 @@ const comparisonFeatures = [
|
||||
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
|
||||
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
|
||||
{ name: 'Domain Valuation', scout: '—', trader: '✓', tycoon: '✓' },
|
||||
{ name: 'SEO Metrics', scout: '—', trader: '—', tycoon: '✓' },
|
||||
{ name: 'API Access', scout: '—', trader: '—', tycoon: '✓' },
|
||||
{ name: 'Webhooks', scout: '—', trader: '—', tycoon: '✓' },
|
||||
{ name: 'SMS/Telegram', scout: '—', trader: '✓', tycoon: '✓' },
|
||||
{ name: 'Price History', scout: '—', trader: '90 days', tycoon: 'Unlimited' },
|
||||
{ name: 'Expiry Tracking', scout: '—', trader: '✓', tycoon: '✓' },
|
||||
]
|
||||
|
||||
const faqs = [
|
||||
@ -99,10 +95,6 @@ const faqs = [
|
||||
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.',
|
||||
},
|
||||
{
|
||||
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?',
|
||||
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() {
|
||||
const router = useRouter()
|
||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
|
||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
|
||||
const getPrice = (basePrice: string) => {
|
||||
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
|
||||
const handleSelectPlan = async (planId: string, isPaid: boolean) => {
|
||||
if (!isAuthenticated) {
|
||||
// Save intended plan and redirect to register
|
||||
sessionStorage.setItem('intended_plan', tier.id)
|
||||
router.push('/register')
|
||||
router.push(`/register?redirect=/pricing`)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticated user - create Stripe checkout
|
||||
setLoadingPlan(tier.id)
|
||||
|
||||
if (!isPaid) {
|
||||
router.push('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingPlan(planId)
|
||||
try {
|
||||
const { checkout_url } = await api.createCheckoutSession(
|
||||
tier.id,
|
||||
`${window.location.origin}/dashboard?upgraded=true&plan=${tier.id}`,
|
||||
`${window.location.origin}/pricing?cancelled=true`
|
||||
const response = await api.createCheckoutSession(
|
||||
planId,
|
||||
`${window.location.origin}/dashboard?upgraded=true`,
|
||||
`${window.location.origin}/pricing`
|
||||
)
|
||||
|
||||
// Redirect to Stripe Checkout
|
||||
window.location.href = checkout_url
|
||||
} catch (err: any) {
|
||||
console.error('Checkout error:', err)
|
||||
setError(err.message || 'Failed to start checkout. Please try again.')
|
||||
window.location.href = response.checkout_url
|
||||
} catch (error) {
|
||||
console.error('Failed to create checkout:', error)
|
||||
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 (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<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-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>
|
||||
|
||||
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-10 sm:mb-12">
|
||||
<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">
|
||||
<Briefcase className="w-4 h-4 text-accent" />
|
||||
<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">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Pricing</span>
|
||||
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
Pick your weapon.
|
||||
</h1>
|
||||
<p className="text-body-lg text-foreground-muted max-w-2xl mx-auto mb-8 animate-slide-up">
|
||||
Casual observer or full-time hunter? We've got you covered.
|
||||
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-xl mx-auto">
|
||||
Start free. Scale when you're ready. All plans include core features.
|
||||
</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>
|
||||
|
||||
|
||||
{/* Pricing Cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4 sm:gap-6 mb-16 sm:mb-20">
|
||||
{tiers.map((tier, i) => (
|
||||
<div className="grid md:grid-cols-3 gap-6 mb-20 animate-slide-up">
|
||||
{tiers.map((tier, index) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
key={tier.id}
|
||||
className={clsx(
|
||||
"relative p-6 sm:p-8 rounded-2xl border transition-all duration-500 animate-slide-up",
|
||||
tier.highlighted
|
||||
? 'bg-background-secondary border-accent/30 shadow-[0_0_60px_-20px_rgba(16,185,129,0.3)]'
|
||||
: 'bg-background-secondary/50 border-border hover:border-border-hover'
|
||||
"group relative p-6 sm:p-8 rounded-2xl border transition-all duration-500",
|
||||
tier.highlighted
|
||||
? "bg-background-secondary border-accent/30 shadow-lg shadow-accent/5"
|
||||
: "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 && (
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||
<span className={clsx(
|
||||
"px-3 py-1 text-ui-xs font-medium rounded-full",
|
||||
tier.highlighted
|
||||
? "bg-accent text-background"
|
||||
: "bg-foreground text-background"
|
||||
)}>
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<span className="px-3 py-1 bg-accent text-background text-ui-xs font-medium rounded-full whitespace-nowrap">
|
||||
{tier.badge}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Icon & Name */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={clsx(
|
||||
"w-10 h-10 rounded-xl flex items-center justify-center",
|
||||
tier.highlighted ? "bg-accent/20" : "bg-background-tertiary"
|
||||
)}>
|
||||
<tier.icon className={clsx(
|
||||
"w-5 h-5",
|
||||
tier.highlighted ? "text-accent" : "text-foreground-muted"
|
||||
)} />
|
||||
</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"
|
||||
|
||||
<div className="relative">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className={clsx(
|
||||
"w-12 h-12 rounded-2xl flex items-center justify-center border transition-all duration-500",
|
||||
tier.highlighted
|
||||
? "bg-accent/10 border-accent/30"
|
||||
: "bg-foreground/5 border-border group-hover:border-accent/30 group-hover:bg-accent/5"
|
||||
)}>
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA Button */}
|
||||
<button
|
||||
onClick={() => handleSelectPlan(tier)}
|
||||
disabled={loadingPlan === tier.id}
|
||||
className={clsx(
|
||||
"w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-ui font-medium transition-all",
|
||||
tier.highlighted
|
||||
? 'bg-accent text-background hover:bg-accent-hover disabled:bg-accent/50'
|
||||
: 'bg-foreground text-background hover:bg-foreground/90 disabled:bg-foreground/50',
|
||||
loadingPlan === tier.id && 'cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{loadingPlan === tier.id ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{tier.cta}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<tier.icon className={clsx(
|
||||
"w-5 h-5 transition-colors duration-500",
|
||||
tier.highlighted ? "text-accent" : "text-foreground-muted group-hover:text-accent"
|
||||
)} />
|
||||
</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>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-3 mb-8">
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature.text} className="flex items-start gap-3">
|
||||
<Check className={clsx(
|
||||
"w-4 h-4 mt-0.5 shrink-0",
|
||||
feature.highlight ? "text-accent" : "text-foreground-muted"
|
||||
)} strokeWidth={2.5} />
|
||||
<span className={clsx(
|
||||
"text-body-sm",
|
||||
feature.highlight ? "text-foreground" : "text-foreground-muted"
|
||||
)}>
|
||||
{feature.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<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>
|
||||
|
||||
{/* Feature Comparison Table */}
|
||||
<div className="mb-16 sm:mb-20">
|
||||
<h2 className="text-heading-md font-medium text-foreground text-center mb-8">
|
||||
Compare Plans
|
||||
</h2>
|
||||
<div className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left text-ui-sm text-foreground-subtle font-medium px-6 py-4">Feature</th>
|
||||
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Scout</th>
|
||||
<th className="text-center text-ui-sm text-accent font-medium px-4 py-4 bg-accent/5">Trader</th>
|
||||
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Tycoon</th>
|
||||
{/* Comparison Table */}
|
||||
<div className="mb-20">
|
||||
<h2 className="text-heading-md text-foreground text-center mb-8">Compare Plans</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-4 px-4 text-body-sm font-medium text-foreground-muted">Feature</th>
|
||||
<th className="text-center py-4 px-4 text-body-sm font-medium text-foreground-muted">Scout</th>
|
||||
<th className="text-center py-4 px-4 text-body-sm font-medium text-accent">Trader</th>
|
||||
<th className="text-center py-4 px-4 text-body-sm font-medium text-foreground-muted">Tycoon</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{comparisonFeatures.map((feature) => (
|
||||
<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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust Badges */}
|
||||
<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 */}
|
||||
{/* FAQ */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="text-heading-md font-medium text-foreground text-center mb-8">
|
||||
Frequently Asked Questions
|
||||
</h2>
|
||||
|
||||
<h2 className="text-heading-md text-foreground text-center mb-8">Frequently Asked</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all"
|
||||
<div
|
||||
key={i}
|
||||
className="border border-border rounded-xl overflow-hidden"
|
||||
>
|
||||
<h3 className="text-body font-medium text-foreground mb-2">{faq.q}</h3>
|
||||
<p className="text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p>
|
||||
<button
|
||||
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>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-16 sm:mt-20 text-center">
|
||||
{/* Bottom CTA */}
|
||||
<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">
|
||||
Not sure which plan is right for you?
|
||||
Start with Scout. It's free forever. Upgrade when you need more.
|
||||
</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
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"
|
||||
href={isAuthenticated ? "/dashboard" : "/register"}
|
||||
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" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
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() {
|
||||
return (
|
||||
<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 = [
|
||||
'Track up to 5 domains. Free.',
|
||||
'Daily scans. You never miss a drop.',
|
||||
@ -27,8 +48,9 @@ const benefits = [
|
||||
'Expiry intel. Plan your move.',
|
||||
]
|
||||
|
||||
export default function RegisterPage() {
|
||||
function RegisterForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { register } = useStore()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
@ -36,6 +58,16 @@ export default function RegisterPage() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
@ -44,7 +76,8 @@ export default function RegisterPage() {
|
||||
|
||||
try {
|
||||
await register(email, password)
|
||||
router.push('/dashboard')
|
||||
// Show verification message
|
||||
setRegistered(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||
} 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'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 (
|
||||
<div className="min-h-screen flex relative">
|
||||
{/* Ambient glow */}
|
||||
@ -69,7 +147,9 @@ export default function RegisterPage() {
|
||||
|
||||
{/* Header */}
|
||||
<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">
|
||||
Start tracking domains in under a minute
|
||||
</p>
|
||||
@ -90,18 +170,20 @@ export default function RegisterPage() {
|
||||
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
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create password (min. 8 characters)"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Create password (min. 8 characters)"
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="input-elegant text-body-sm sm:text-body pr-12"
|
||||
/>
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
@ -135,10 +217,50 @@ export default function RegisterPage() {
|
||||
</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" />
|
||||
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 */}
|
||||
<p className="mt-8 sm:mt-10 text-body-xs sm:text-body-sm text-foreground-muted">
|
||||
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
|
||||
</Link>
|
||||
</p>
|
||||
@ -178,3 +300,15 @@ export default function RegisterPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -182,18 +182,32 @@ export default function SettingsPage() {
|
||||
]
|
||||
|
||||
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 />
|
||||
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2">
|
||||
Settings
|
||||
<div className="mb-12 sm:mb-16 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Settings</span>
|
||||
<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>
|
||||
<p className="text-body text-foreground-muted">
|
||||
Your account. Your rules.
|
||||
<p className="mt-3 text-lg text-foreground-muted">
|
||||
Your rules. Configure everything in one place.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -218,9 +232,9 @@ export default function SettingsPage() {
|
||||
</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 */}
|
||||
<div className="lg:w-64 shrink-0">
|
||||
<div className="lg:w-72 shrink-0">
|
||||
{/* Mobile: Horizontal scroll tabs */}
|
||||
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
|
||||
{tabs.map((tab) => (
|
||||
@ -228,10 +242,10 @@ export default function SettingsPage() {
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
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
|
||||
? "bg-foreground text-background shadow-lg"
|
||||
: "bg-background-secondary text-foreground-muted hover:text-foreground border border-border"
|
||||
? "bg-accent text-background shadow-lg shadow-accent/20"
|
||||
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border hover:border-accent/30"
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
@ -241,15 +255,15 @@ export default function SettingsPage() {
|
||||
</nav>
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
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
|
||||
? "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"
|
||||
)}
|
||||
>
|
||||
@ -260,13 +274,13 @@ export default function SettingsPage() {
|
||||
</nav>
|
||||
|
||||
{/* 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="flex items-center gap-2 mb-2">
|
||||
{isProOrHigher ? <Crown className="w-4 h-4 text-accent" /> : <Zap className="w-4 h-4 text-accent" />}
|
||||
<span className="text-body-sm font-medium text-foreground">{tierName} Plan</span>
|
||||
<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-3">
|
||||
{isProOrHigher ? <Crown className="w-5 h-5 text-accent" /> : <Zap className="w-5 h-5 text-accent" />}
|
||||
<span className="text-sm font-semibold text-foreground">{tierName} Plan</span>
|
||||
</div>
|
||||
<p className="text-body-xs text-foreground-muted mb-3">
|
||||
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains
|
||||
<p className="text-xs text-foreground-muted mb-4">
|
||||
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains tracked
|
||||
</p>
|
||||
{!isProOrHigher && (
|
||||
<Link
|
||||
@ -495,18 +509,53 @@ export default function SettingsPage() {
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-body-sm font-medium text-foreground">Plan Features</h3>
|
||||
<ul className="space-y-2">
|
||||
{subscription?.features && Object.entries(subscription.features).map(([key, value]) => (
|
||||
<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'}>
|
||||
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
{subscription?.features && Object.entries(subscription.features)
|
||||
.filter(([key]) => !['sms_alerts', 'api_access', 'webhooks', 'bulk_tools', 'seo_metrics'].includes(key))
|
||||
.map(([key, value]) => {
|
||||
const featureNames: Record<string, string> = {
|
||||
email_alerts: 'Email Alerts',
|
||||
priority_alerts: 'Priority Alerts',
|
||||
full_whois: 'Full WHOIS Data',
|
||||
expiration_tracking: 'Expiry Tracking',
|
||||
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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -391,9 +391,12 @@ function DomainResultCard({
|
||||
export default function TldDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
||||
const { isAuthenticated, checkAuth, isLoading: authLoading, subscription, fetchSubscription } = useStore()
|
||||
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 [history, setHistory] = useState<TldHistory | null>(null)
|
||||
const [relatedTlds, setRelatedTlds] = useState<Array<{ tld: string; price: number }>>([])
|
||||
@ -408,7 +411,8 @@ export default function TldDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth()
|
||||
}, [checkAuth])
|
||||
fetchSubscription()
|
||||
}, [checkAuth, fetchSubscription])
|
||||
|
||||
useEffect(() => {
|
||||
if (tld) {
|
||||
@ -797,8 +801,13 @@ export default function TldDetailPage() {
|
||||
{/* Price Chart */}
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-body-lg font-medium text-foreground">Price History</h2>
|
||||
{isAuthenticated && (
|
||||
<div className="flex items-center gap-3">
|
||||
<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">
|
||||
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => (
|
||||
<button
|
||||
@ -819,29 +828,56 @@ export default function TldDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||
<PriceChart
|
||||
data={filteredHistory}
|
||||
isAuthenticated={isAuthenticated}
|
||||
chartStats={chartStats}
|
||||
/>
|
||||
|
||||
{isAuthenticated && 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>
|
||||
{!isAuthenticated ? (
|
||||
<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">
|
||||
<Lock className="w-5 h-5 text-foreground-subtle" />
|
||||
<span className="text-ui-sm text-foreground-muted">Sign in to view price history</span>
|
||||
<Link href={`/login?redirect=/tld-pricing/${tld}`} className="text-ui-sm text-accent hover:text-accent-hover transition-colors">
|
||||
Sign in →
|
||||
</Link>
|
||||
</div>
|
||||
<span className="text-foreground-subtle">Today</span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@ -258,26 +258,31 @@ export default function TldPricingPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background relative flex flex-col">
|
||||
{/* Ambient glow */}
|
||||
<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-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>
|
||||
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-12 sm: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">
|
||||
<BarChart3 className="w-4 h-4 text-accent" />
|
||||
<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">
|
||||
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Market Intel</span>
|
||||
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||
{pagination.total}+ TLDs. Live Prices.
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -1,128 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useEffect, useState, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { api } from '@/lib/api'
|
||||
import { Mail, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { CheckCircle, XCircle, Loader2, Mail, ArrowRight } from 'lucide-react'
|
||||
|
||||
function VerifyEmailContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'no-token'>('loading')
|
||||
const [message, setMessage] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [resending, setResending] = useState(false)
|
||||
const [resendSuccess, setResendSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setError('Invalid or missing verification token.')
|
||||
setStatus('no-token')
|
||||
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()
|
||||
}, [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 (
|
||||
<>
|
||||
<Header />
|
||||
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
|
||||
<div className="w-full max-w-md">
|
||||
{status === 'loading' && (
|
||||
<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">
|
||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||
</div>
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
||||
Verifying your email...
|
||||
</h1>
|
||||
<p className="text-foreground-muted">
|
||||
Please wait while we verify your email address.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||
{/* Background Effects */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||
<div
|
||||
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>
|
||||
|
||||
{status === 'success' && (
|
||||
<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">
|
||||
<CheckCircle className="w-8 h-8 text-accent" />
|
||||
<Header />
|
||||
|
||||
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||
<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>
|
||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
||||
Email verified!
|
||||
</h1>
|
||||
<p className="text-foreground-muted mb-6">
|
||||
Your email has been verified successfully. Redirecting you to login...
|
||||
</p>
|
||||
<Link
|
||||
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"
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<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
|
||||
href="/login"
|
||||
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>
|
||||
</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>
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<main className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-foreground-muted">Loading...</div>
|
||||
</main>
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||
</div>
|
||||
}>
|
||||
<VerifyEmailContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
57
frontend/src/components/Breadcrumbs.tsx
Normal file
57
frontend/src/components/Breadcrumbs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Github, Twitter, Mail } from 'lucide-react'
|
||||
import { useStore } from '@/lib/store'
|
||||
|
||||
export function Footer() {
|
||||
const { isAuthenticated } = useStore()
|
||||
|
||||
return (
|
||||
<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">
|
||||
@ -10,13 +15,15 @@ export function Footer() {
|
||||
{/* Brand */}
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="mb-4">
|
||||
<Image
|
||||
src="/pounce-logo.png"
|
||||
alt="pounce"
|
||||
width={120}
|
||||
height={60}
|
||||
className="w-28 h-auto"
|
||||
/>
|
||||
<Link href="/" className="inline-block">
|
||||
<Image
|
||||
src="/pounce-logo.png"
|
||||
alt="pounce"
|
||||
width={120}
|
||||
height={60}
|
||||
className="w-28 h-auto"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-body-sm text-foreground-muted mb-4 max-w-xs">
|
||||
Domain intelligence for hunters. Track. Alert. Pounce.
|
||||
@ -27,6 +34,7 @@ export function Footer() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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" />
|
||||
</a>
|
||||
@ -35,59 +43,63 @@ export function Footer() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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" />
|
||||
</a>
|
||||
<a
|
||||
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"
|
||||
aria-label="Email"
|
||||
>
|
||||
<Mail className="w-4 h-4 text-foreground-muted" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product */}
|
||||
{/* Product - Matches Header nav */}
|
||||
<div>
|
||||
<h3 className="text-ui font-medium text-foreground mb-4">Product</h3>
|
||||
<ul className="space-y-3">
|
||||
<li>
|
||||
<Link href="/" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Domain Monitoring
|
||||
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
TLD Prices
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
TLD Pricing
|
||||
<Link href="/auctions" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Auctions
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Pricing Plans
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/dashboard" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Dashboard
|
||||
Pricing
|
||||
</Link>
|
||||
</li>
|
||||
{isAuthenticated && (
|
||||
<li>
|
||||
<Link href="/dashboard" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Command Center
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
{/* Resources */}
|
||||
<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">
|
||||
<li>
|
||||
<Link href="/about" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/blog" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Blog
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/about" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
About
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Contact
|
||||
@ -120,11 +132,6 @@ export function Footer() {
|
||||
Imprint
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/unsubscribe" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||
Unsubscribe
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -150,5 +157,3 @@ export function Footer() {
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -1,12 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
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 clsx from 'clsx'
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname()
|
||||
const { isAuthenticated, user, logout, domains, subscription } = useStore()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
@ -28,18 +44,35 @@ export function Header() {
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false)
|
||||
}, [pathname])
|
||||
|
||||
// Count notifications (available domains, etc.)
|
||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||
const hasNotifications = availableDomains.length > 0
|
||||
|
||||
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 (
|
||||
<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 */}
|
||||
<div className="flex items-center gap-6 sm:gap-8 h-full">
|
||||
{/* Logo - Playfair Display font */}
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center h-full hover:opacity-80 transition-opacity duration-300"
|
||||
@ -52,53 +85,41 @@ export function Header() {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Left Nav Links (Desktop) - vertically centered */}
|
||||
<nav className="hidden sm:flex items-center h-full gap-1">
|
||||
<Link
|
||||
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 && (
|
||||
{/* Main Nav Links (Desktop) */}
|
||||
<nav className="hidden md:flex items-center h-full gap-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
href="/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"
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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>
|
||||
)}
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right side: Auth Links - vertically centered */}
|
||||
{/* Right side */}
|
||||
<nav className="hidden sm:flex items-center h-full gap-2">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{/* Dashboard Link */}
|
||||
{/* Command Center Link - Primary CTA when logged in */}
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="flex items-center gap-2 h-9 px-4 text-[0.8125rem] font-medium text-foreground
|
||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg transition-all duration-300"
|
||||
className={clsx(
|
||||
"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" />
|
||||
<span>Dashboard</span>
|
||||
<span>Command Center</span>
|
||||
</Link>
|
||||
|
||||
{/* Notifications */}
|
||||
@ -106,8 +127,10 @@ export function Header() {
|
||||
<button
|
||||
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||
className={clsx(
|
||||
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300",
|
||||
notificationsOpen ? "bg-foreground/10 text-foreground" : "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
@ -127,7 +150,7 @@ export function Header() {
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{availableDomains.length > 0 ? (
|
||||
<div className="p-2">
|
||||
{availableDomains.map((domain) => (
|
||||
{availableDomains.slice(0, 5).map((domain) => (
|
||||
<Link
|
||||
key={domain.id}
|
||||
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"
|
||||
>
|
||||
<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 className="flex-1 min-w-0">
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
{availableDomains.length > 5 && (
|
||||
<p className="px-3 py-2 text-body-xs text-foreground-muted">
|
||||
+{availableDomains.length - 5} more available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
@ -157,7 +185,7 @@ export function Header() {
|
||||
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"
|
||||
>
|
||||
Manage notifications
|
||||
Notification settings
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
@ -168,14 +196,17 @@ export function Header() {
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
{/* 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-xs text-foreground-muted truncate">{user?.email}</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="p-2">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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"
|
||||
>
|
||||
<LayoutDashboard className="w-4 h-4" />
|
||||
Command Center
|
||||
</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>
|
||||
{user?.is_admin && (
|
||||
<Link
|
||||
href="/admin"
|
||||
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"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Admin Panel
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setUserMenuOpen(false)}
|
||||
@ -241,14 +266,14 @@ export function Header() {
|
||||
<Link
|
||||
href="/login"
|
||||
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
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
className="flex items-center h-9 ml-2 px-5 text-[0.8125rem] bg-foreground text-background rounded-lg
|
||||
font-medium hover:bg-foreground/90 transition-all duration-300"
|
||||
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-200"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
@ -268,54 +293,71 @@ export function Header() {
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="sm:hidden border-t border-border bg-background/95 backdrop-blur-xl">
|
||||
<nav className="px-4 py-4 space-y-2">
|
||||
{isAuthenticated ? (
|
||||
<nav className="px-4 py-4 space-y-1">
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{/* 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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)}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 px-4 py-3 text-body-sm rounded-xl transition-all duration-200",
|
||||
isActive('/dashboard')
|
||||
? "bg-foreground text-background font-medium"
|
||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||
)}
|
||||
>
|
||||
<LayoutDashboard className="w-5 h-5" />
|
||||
<span>Dashboard</span>
|
||||
</Link>
|
||||
<Link
|
||||
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>
|
||||
<span>Command Center</span>
|
||||
{hasNotifications && (
|
||||
<span className="ml-auto w-2 h-2 bg-accent rounded-full" />
|
||||
)}
|
||||
</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
|
||||
href="/settings"
|
||||
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)}
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span>Settings</span>
|
||||
@ -326,8 +368,7 @@ export function Header() {
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
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
|
||||
transition-all duration-300"
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Sign Out</span>
|
||||
@ -335,56 +376,18 @@ export function Header() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
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>
|
||||
<div className="my-3 border-t border-border" />
|
||||
<Link
|
||||
href="/login"
|
||||
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)}
|
||||
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
href="/register"
|
||||
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"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
rounded-xl font-medium hover:bg-foreground/90 transition-all duration-200"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
|
||||
94
frontend/src/components/Toast.tsx
Normal file
94
frontend/src/components/Toast.tsx
Normal 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 }
|
||||
}
|
||||
|
||||
@ -85,8 +85,11 @@ class ApiClient {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ApiError = await response.json().catch(() => ({ detail: 'An error occurred' }))
|
||||
throw new Error(error.detail)
|
||||
const errorData = await response.json().catch(() => ({ detail: 'An error occurred' }))
|
||||
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) {
|
||||
@ -126,6 +129,8 @@ class ApiClient {
|
||||
email: string
|
||||
name: string | null
|
||||
is_active: boolean
|
||||
is_admin: boolean
|
||||
is_verified: boolean
|
||||
created_at: string
|
||||
}>('/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
|
||||
async submitContact(name: string, email: string, subject: string, message: string) {
|
||||
return this.request<{ message: string; success: boolean }>('/contact', {
|
||||
@ -308,6 +328,7 @@ class ApiClient {
|
||||
status: string
|
||||
domain_limit: number
|
||||
domains_used: number
|
||||
portfolio_limit: number
|
||||
check_frequency: string
|
||||
history_days: number
|
||||
features: {
|
||||
@ -315,8 +336,12 @@ class ApiClient {
|
||||
priority_alerts: boolean
|
||||
full_whois: boolean
|
||||
expiration_tracking: boolean
|
||||
domain_valuation: boolean
|
||||
market_insights: boolean
|
||||
api_access: boolean
|
||||
webhooks: boolean
|
||||
bulk_tools: boolean
|
||||
seo_metrics: boolean
|
||||
}
|
||||
started_at: string
|
||||
expires_at: string | null
|
||||
@ -517,7 +542,7 @@ class ApiClient {
|
||||
maxBid?: number,
|
||||
endingSoon = false,
|
||||
sortBy = 'ending',
|
||||
limit = 20,
|
||||
limit = 100,
|
||||
offset = 0
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
@ -824,6 +849,15 @@ class AdminApiClient extends ApiClient {
|
||||
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
|
||||
async getSystemHealth() {
|
||||
return this.request<any>('/admin/system/health')
|
||||
@ -832,6 +866,239 @@ class AdminApiClient extends ApiClient {
|
||||
async makeUserAdmin(email: string) {
|
||||
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()
|
||||
|
||||
@ -8,6 +8,8 @@ interface User {
|
||||
id: number
|
||||
email: string
|
||||
name: string | null
|
||||
is_admin?: boolean
|
||||
is_verified?: boolean
|
||||
}
|
||||
|
||||
interface Domain {
|
||||
@ -27,6 +29,7 @@ interface Subscription {
|
||||
tier_name?: string
|
||||
domain_limit: number
|
||||
domains_used: number
|
||||
portfolio_limit?: number
|
||||
check_frequency?: string
|
||||
history_days?: number
|
||||
features?: {
|
||||
@ -34,8 +37,12 @@ interface Subscription {
|
||||
priority_alerts: boolean
|
||||
full_whois: boolean
|
||||
expiration_tracking: boolean
|
||||
domain_valuation: boolean
|
||||
market_insights: boolean
|
||||
api_access: 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,
|
||||
domain_limit: sub.domain_limit,
|
||||
domains_used: sub.domains_used,
|
||||
portfolio_limit: sub.portfolio_limit,
|
||||
check_frequency: sub.check_frequency,
|
||||
history_days: sub.history_days,
|
||||
features: sub.features,
|
||||
|
||||
Reference in New Issue
Block a user