Compare commits
2 Commits
ed050782b6
...
339e89e65d
| Author | SHA1 | Date | |
|---|---|---|---|
| 339e89e65d | |||
| cff0ba0984 |
68
README.md
68
README.md
@ -121,12 +121,32 @@ npm run dev
|
|||||||
### Security Features (v1.1)
|
### Security Features (v1.1)
|
||||||
- **Password Reset** — Secure token-based password recovery via email
|
- **Password Reset** — Secure token-based password recovery via email
|
||||||
- **Email Verification** — Optional email confirmation for new accounts
|
- **Email Verification** — Optional email confirmation for new accounts
|
||||||
|
- **OAuth Login** — Sign in with Google or GitHub
|
||||||
- **Rate Limiting** — Protection against brute-force attacks (slowapi)
|
- **Rate Limiting** — Protection against brute-force attacks (slowapi)
|
||||||
- **Stripe Payments** — Secure subscription payments with Stripe Checkout
|
- **Stripe Payments** — Secure subscription payments with Stripe Checkout
|
||||||
- **Stripe Customer Portal** — Manage billing, view invoices, cancel subscriptions
|
- **Stripe Customer Portal** — Manage billing, view invoices, cancel subscriptions
|
||||||
- **Contact Form** — With email confirmation and spam protection
|
- **Contact Form** — With email confirmation and spam protection
|
||||||
- **Newsletter** — Subscribe/unsubscribe with double opt-in
|
- **Newsletter** — Subscribe/unsubscribe with double opt-in
|
||||||
|
|
||||||
|
### Blog System (v1.3)
|
||||||
|
- **Blog Posts** — Create and manage blog articles
|
||||||
|
- **Categories & Tags** — Organize content
|
||||||
|
- **Featured Posts** — Highlight posts on homepage
|
||||||
|
- **View Tracking** — Analytics for post views
|
||||||
|
- **SEO Metadata** — Custom meta titles and descriptions
|
||||||
|
- **Draft/Publish** — Content workflow management
|
||||||
|
|
||||||
|
### Admin Panel (v1.3)
|
||||||
|
- **User Management** — List, search, upgrade, and manage users
|
||||||
|
- **Bulk Operations** — Upgrade multiple users at once
|
||||||
|
- **User Export** — Export all users to CSV
|
||||||
|
- **Price Alerts** — View all active TLD price alerts
|
||||||
|
- **Domain Health** — Manually trigger domain checks
|
||||||
|
- **Scheduler Status** — Monitor background jobs and last runs
|
||||||
|
- **Email Test** — Verify SMTP configuration
|
||||||
|
- **Activity Log** — Track admin actions
|
||||||
|
- **Blog Management** — Full CRUD for blog posts
|
||||||
|
|
||||||
### CI/CD Pipeline (v1.2)
|
### CI/CD Pipeline (v1.2)
|
||||||
- **GitHub Actions** — Automated CI/CD on push to main
|
- **GitHub Actions** — Automated CI/CD on push to main
|
||||||
- **Frontend Lint** — ESLint + TypeScript type checking
|
- **Frontend Lint** — ESLint + TypeScript type checking
|
||||||
@ -266,7 +286,9 @@ pounce/
|
|||||||
| `/auctions` | Smart Pounce auction aggregator | No* |
|
| `/auctions` | Smart Pounce auction aggregator | No* |
|
||||||
| `/contact` | Contact form | No |
|
| `/contact` | Contact form | No |
|
||||||
| `/about` | About us | No |
|
| `/about` | About us | No |
|
||||||
| `/blog` | Blog & Newsletter signup | No |
|
| `/blog` | Blog posts listing | No |
|
||||||
|
| `/blog/{slug}` | Blog post detail | No |
|
||||||
|
| `/admin` | Admin panel | Yes (Admin) |
|
||||||
| `/privacy` | Privacy policy | No |
|
| `/privacy` | Privacy policy | No |
|
||||||
| `/terms` | Terms of service | No |
|
| `/terms` | Terms of service | No |
|
||||||
| `/imprint` | Legal imprint | No |
|
| `/imprint` | Legal imprint | No |
|
||||||
@ -525,13 +547,51 @@ This ensures identical prices on:
|
|||||||
| GET | `/api/v1/auctions/opportunities` | AI-powered opportunities (auth) |
|
| GET | `/api/v1/auctions/opportunities` | AI-powered opportunities (auth) |
|
||||||
| GET | `/api/v1/auctions/stats` | Platform statistics |
|
| GET | `/api/v1/auctions/stats` | Platform statistics |
|
||||||
|
|
||||||
|
### OAuth
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/oauth/providers` | List enabled OAuth providers |
|
||||||
|
| GET | `/api/v1/oauth/google/login` | Initiate Google OAuth |
|
||||||
|
| GET | `/api/v1/oauth/google/callback` | Handle Google callback |
|
||||||
|
| GET | `/api/v1/oauth/github/login` | Initiate GitHub OAuth |
|
||||||
|
| GET | `/api/v1/oauth/github/callback` | Handle GitHub callback |
|
||||||
|
|
||||||
|
### Blog
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/blog/posts` | List published posts |
|
||||||
|
| GET | `/api/v1/blog/posts/featured` | Get featured posts |
|
||||||
|
| GET | `/api/v1/blog/posts/categories` | Get all categories |
|
||||||
|
| GET | `/api/v1/blog/posts/{slug}` | Get single post |
|
||||||
|
| GET | `/api/v1/blog/admin/posts` | Admin: List all posts |
|
||||||
|
| POST | `/api/v1/blog/admin/posts` | Admin: Create post |
|
||||||
|
| GET | `/api/v1/blog/admin/posts/{id}` | Admin: Get post |
|
||||||
|
| PATCH | `/api/v1/blog/admin/posts/{id}` | Admin: Update post |
|
||||||
|
| DELETE | `/api/v1/blog/admin/posts/{id}` | Admin: Delete post |
|
||||||
|
| POST | `/api/v1/blog/admin/posts/{id}/publish` | Admin: Publish post |
|
||||||
|
| POST | `/api/v1/blog/admin/posts/{id}/unpublish` | Admin: Unpublish post |
|
||||||
|
|
||||||
### Admin
|
### Admin
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/admin/stats` | Get platform statistics |
|
||||||
| GET | `/api/v1/admin/users` | List all users |
|
| GET | `/api/v1/admin/users` | List all users |
|
||||||
| POST | `/api/v1/admin/upgrade-user` | Upgrade user subscription |
|
| GET | `/api/v1/admin/users/export` | Export users as CSV |
|
||||||
| POST | `/api/v1/admin/scrape-tld-prices` | Manually trigger TLD price scrape |
|
| POST | `/api/v1/admin/users/bulk-upgrade` | Bulk upgrade users |
|
||||||
| GET | `/api/v1/admin/tld-prices/stats` | Get TLD price database stats |
|
| GET | `/api/v1/admin/users/{id}` | Get user details |
|
||||||
|
| PATCH | `/api/v1/admin/users/{id}` | Update user |
|
||||||
|
| DELETE | `/api/v1/admin/users/{id}` | Delete user |
|
||||||
|
| POST | `/api/v1/admin/users/{id}/upgrade` | Upgrade single user |
|
||||||
|
| GET | `/api/v1/admin/price-alerts` | List all price alerts |
|
||||||
|
| POST | `/api/v1/admin/domains/check-all` | Trigger domain checks |
|
||||||
|
| GET | `/api/v1/admin/newsletter` | List newsletter subs |
|
||||||
|
| GET | `/api/v1/admin/newsletter/export` | Export newsletter |
|
||||||
|
| POST | `/api/v1/admin/scrape-tld-prices` | Trigger TLD scrape |
|
||||||
|
| GET | `/api/v1/admin/tld-prices/stats` | Get TLD stats |
|
||||||
|
| GET | `/api/v1/admin/system/health` | System health check |
|
||||||
|
| GET | `/api/v1/admin/system/scheduler` | Scheduler status |
|
||||||
|
| POST | `/api/v1/admin/system/test-email` | Send test email |
|
||||||
|
| GET | `/api/v1/admin/activity-log` | Get activity log |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
|
from app.api.oauth import router as oauth_router
|
||||||
from app.api.domains import router as domains_router
|
from app.api.domains import router as domains_router
|
||||||
from app.api.check import router as check_router
|
from app.api.check import router as check_router
|
||||||
from app.api.subscription import router as subscription_router
|
from app.api.subscription import router as subscription_router
|
||||||
@ -12,11 +13,13 @@ from app.api.auctions import router as auctions_router
|
|||||||
from app.api.webhooks import router as webhooks_router
|
from app.api.webhooks import router as webhooks_router
|
||||||
from app.api.contact import router as contact_router
|
from app.api.contact import router as contact_router
|
||||||
from app.api.price_alerts import router as price_alerts_router
|
from app.api.price_alerts import router as price_alerts_router
|
||||||
|
from app.api.blog import router as blog_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
# Core API endpoints
|
# Core API endpoints
|
||||||
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
api_router.include_router(auth_router, prefix="/auth", tags=["Authentication"])
|
||||||
|
api_router.include_router(oauth_router, prefix="/oauth", tags=["OAuth"])
|
||||||
api_router.include_router(check_router, prefix="/check", tags=["Domain Check"])
|
api_router.include_router(check_router, prefix="/check", tags=["Domain Check"])
|
||||||
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
|
api_router.include_router(domains_router, prefix="/domains", tags=["Domain Management"])
|
||||||
api_router.include_router(subscription_router, prefix="/subscription", tags=["Subscription"])
|
api_router.include_router(subscription_router, prefix="/subscription", tags=["Subscription"])
|
||||||
@ -31,5 +34,8 @@ api_router.include_router(contact_router, prefix="/contact", tags=["Contact & Ne
|
|||||||
# Webhooks (external service callbacks)
|
# Webhooks (external service callbacks)
|
||||||
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
|
api_router.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"])
|
||||||
|
|
||||||
|
# Content
|
||||||
|
api_router.include_router(blog_router, prefix="/blog", tags=["Blog"])
|
||||||
|
|
||||||
# Admin endpoints
|
# Admin endpoints
|
||||||
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
api_router.include_router(admin_router, prefix="/admin", tags=["Admin"])
|
||||||
|
|||||||
@ -226,6 +226,65 @@ async def list_users(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== User Export ==============
|
||||||
|
# NOTE: This must come BEFORE /users/{user_id} to avoid route conflict
|
||||||
|
|
||||||
|
@router.get("/users/export")
|
||||||
|
async def export_users_csv(
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Export all users as CSV data."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
result = await db.execute(select(User).order_by(User.created_at))
|
||||||
|
users_list = result.scalars().all()
|
||||||
|
|
||||||
|
# Create CSV
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
|
||||||
|
# Header
|
||||||
|
writer.writerow([
|
||||||
|
"ID", "Email", "Name", "Active", "Verified", "Admin",
|
||||||
|
"Created At", "Last Login", "Tier", "Domain Limit", "Domains Used"
|
||||||
|
])
|
||||||
|
|
||||||
|
for user in users_list:
|
||||||
|
# Get subscription
|
||||||
|
sub_result = await db.execute(
|
||||||
|
select(Subscription).where(Subscription.user_id == user.id)
|
||||||
|
)
|
||||||
|
subscription = sub_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
# Get domain count
|
||||||
|
domain_count = await db.execute(
|
||||||
|
select(func.count(Domain.id)).where(Domain.user_id == user.id)
|
||||||
|
)
|
||||||
|
domain_count = domain_count.scalar()
|
||||||
|
|
||||||
|
writer.writerow([
|
||||||
|
user.id,
|
||||||
|
user.email,
|
||||||
|
user.name or "",
|
||||||
|
"Yes" if user.is_active else "No",
|
||||||
|
"Yes" if user.is_verified else "No",
|
||||||
|
"Yes" if user.is_admin else "No",
|
||||||
|
user.created_at.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
user.last_login.strftime("%Y-%m-%d %H:%M") if user.last_login else "",
|
||||||
|
subscription.tier.value if subscription else "scout",
|
||||||
|
subscription.domain_limit if subscription else 5,
|
||||||
|
domain_count,
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"csv": output.getvalue(),
|
||||||
|
"count": len(users_list),
|
||||||
|
"exported_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/{user_id}")
|
@router.get("/users/{user_id}")
|
||||||
async def get_user(
|
async def get_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@ -574,3 +633,329 @@ async def make_user_admin(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return {"message": f"User {email} is now an admin"}
|
return {"message": f"User {email} is now an admin"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Price Alerts ==============
|
||||||
|
|
||||||
|
@router.get("/price-alerts")
|
||||||
|
async def list_price_alerts(
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
"""List all active price alerts with user info."""
|
||||||
|
query = (
|
||||||
|
select(PriceAlert, User)
|
||||||
|
.join(User, PriceAlert.user_id == User.id)
|
||||||
|
.where(PriceAlert.is_active == True)
|
||||||
|
.order_by(desc(PriceAlert.created_at))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await db.execute(query)
|
||||||
|
alerts = result.all()
|
||||||
|
|
||||||
|
# Total count
|
||||||
|
count_query = select(func.count(PriceAlert.id)).where(PriceAlert.is_active == True)
|
||||||
|
total = await db.execute(count_query)
|
||||||
|
total = total.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"id": alert.id,
|
||||||
|
"tld": alert.tld,
|
||||||
|
"target_price": float(alert.target_price) if alert.target_price else None,
|
||||||
|
"alert_type": alert.alert_type,
|
||||||
|
"created_at": alert.created_at.isoformat(),
|
||||||
|
"user": {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for alert, user in alerts
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Domain Health ==============
|
||||||
|
|
||||||
|
@router.post("/domains/check-all")
|
||||||
|
async def trigger_domain_checks(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Manually trigger domain availability checks for all watched domains."""
|
||||||
|
from app.services.domain_checker import check_all_domains
|
||||||
|
|
||||||
|
# Count domains to check
|
||||||
|
total_domains = await db.execute(select(func.count(Domain.id)))
|
||||||
|
total_domains = total_domains.scalar()
|
||||||
|
|
||||||
|
if total_domains == 0:
|
||||||
|
return {"message": "No domains to check", "domains_queued": 0}
|
||||||
|
|
||||||
|
# Run in background
|
||||||
|
background_tasks.add_task(check_all_domains, db)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Domain checks started",
|
||||||
|
"domains_queued": total_domains,
|
||||||
|
"started_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Email Test ==============
|
||||||
|
|
||||||
|
@router.post("/system/test-email")
|
||||||
|
async def test_email(
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Send a test email to the admin user."""
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
|
||||||
|
if not email_service.is_configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Email service is not configured. Check SMTP settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await email_service.send_email(
|
||||||
|
to_email=admin.email,
|
||||||
|
subject="pounce Admin Panel - Test Email",
|
||||||
|
html_content=f"""
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #22c55e;">✅ Email Test Successful</h1>
|
||||||
|
<p>This is a test email from the pounce Admin Panel.</p>
|
||||||
|
<p>If you received this, your SMTP configuration is working correctly.</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e5e5; margin: 20px 0;">
|
||||||
|
<p style="color: #666; font-size: 12px;">
|
||||||
|
Sent at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}<br>
|
||||||
|
Admin: {admin.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
text_content=f"Email Test Successful\n\nThis is a test email from the pounce Admin Panel.\nSent at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"message": "Test email sent successfully",
|
||||||
|
"sent_to": admin.email,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to send email: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Scheduler Status ==============
|
||||||
|
|
||||||
|
@router.get("/system/scheduler")
|
||||||
|
async def get_scheduler_status(
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Get scheduler job status and last run times."""
|
||||||
|
from app.scheduler import scheduler
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
for job in scheduler.get_jobs():
|
||||||
|
jobs.append({
|
||||||
|
"id": job.id,
|
||||||
|
"name": job.name,
|
||||||
|
"next_run": job.next_run_time.isoformat() if job.next_run_time else None,
|
||||||
|
"trigger": str(job.trigger),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get last scrape times from database
|
||||||
|
# TLD scrape - check latest TLD price record
|
||||||
|
latest_tld = await db.execute(
|
||||||
|
select(TLDPrice.recorded_at).order_by(desc(TLDPrice.recorded_at)).limit(1)
|
||||||
|
)
|
||||||
|
latest_tld = latest_tld.scalar()
|
||||||
|
|
||||||
|
# Auction scrape - check latest auction record
|
||||||
|
latest_auction = await db.execute(
|
||||||
|
select(DomainAuction.scraped_at).order_by(desc(DomainAuction.scraped_at)).limit(1)
|
||||||
|
)
|
||||||
|
latest_auction = latest_auction.scalar()
|
||||||
|
|
||||||
|
# Domain check - check latest domain check (via Domain.last_checked)
|
||||||
|
latest_domain_check = await db.execute(
|
||||||
|
select(Domain.last_checked).where(Domain.last_checked.isnot(None)).order_by(desc(Domain.last_checked)).limit(1)
|
||||||
|
)
|
||||||
|
latest_domain_check = latest_domain_check.scalar()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scheduler_running": scheduler.running,
|
||||||
|
"jobs": jobs,
|
||||||
|
"last_runs": {
|
||||||
|
"tld_scrape": latest_tld.isoformat() if latest_tld else None,
|
||||||
|
"auction_scrape": latest_auction.isoformat() if latest_auction else None,
|
||||||
|
"domain_check": latest_domain_check.isoformat() if latest_domain_check else None,
|
||||||
|
},
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Bulk Operations ==============
|
||||||
|
|
||||||
|
class BulkUpgradeRequest(BaseModel):
|
||||||
|
"""Request for bulk tier upgrade."""
|
||||||
|
user_ids: list[int]
|
||||||
|
tier: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/users/bulk-upgrade")
|
||||||
|
async def bulk_upgrade_users(
|
||||||
|
request: BulkUpgradeRequest,
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
):
|
||||||
|
"""Upgrade multiple users to a specific tier."""
|
||||||
|
# Validate tier
|
||||||
|
try:
|
||||||
|
new_tier = SubscriptionTier(request.tier)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid tier: {request.tier}. Valid: scout, trader, tycoon"
|
||||||
|
)
|
||||||
|
|
||||||
|
config = TIER_CONFIG.get(new_tier, {})
|
||||||
|
upgraded = []
|
||||||
|
failed = []
|
||||||
|
|
||||||
|
for user_id in request.user_ids:
|
||||||
|
try:
|
||||||
|
# Get user
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
failed.append({"user_id": user_id, "reason": "User not found"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get or create subscription
|
||||||
|
sub_result = await db.execute(
|
||||||
|
select(Subscription).where(Subscription.user_id == user.id)
|
||||||
|
)
|
||||||
|
subscription = sub_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
subscription = Subscription(
|
||||||
|
user_id=user.id,
|
||||||
|
tier=new_tier,
|
||||||
|
status=SubscriptionStatus.ACTIVE,
|
||||||
|
max_domains=config.get("domain_limit", 5),
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
|
else:
|
||||||
|
subscription.tier = new_tier
|
||||||
|
subscription.max_domains = config.get("domain_limit", 5)
|
||||||
|
subscription.status = SubscriptionStatus.ACTIVE
|
||||||
|
|
||||||
|
upgraded.append({"user_id": user_id, "email": user.email})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
failed.append({"user_id": user_id, "reason": str(e)})
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Log activity
|
||||||
|
await log_admin_activity(
|
||||||
|
db, admin.id, "bulk_upgrade",
|
||||||
|
f"Upgraded {len(upgraded)} users to {new_tier.value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": f"Bulk upgrade completed",
|
||||||
|
"tier": new_tier.value,
|
||||||
|
"upgraded": upgraded,
|
||||||
|
"failed": failed,
|
||||||
|
"total_upgraded": len(upgraded),
|
||||||
|
"total_failed": len(failed),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============== Activity Log ==============
|
||||||
|
|
||||||
|
async def log_admin_activity(
|
||||||
|
db: Database,
|
||||||
|
admin_id: int,
|
||||||
|
action: str,
|
||||||
|
details: str,
|
||||||
|
):
|
||||||
|
"""Helper to log admin activities."""
|
||||||
|
from app.models.admin_log import AdminActivityLog
|
||||||
|
|
||||||
|
try:
|
||||||
|
log = AdminActivityLog(
|
||||||
|
admin_id=admin_id,
|
||||||
|
action=action,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
# Don't fail if logging fails
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/activity-log")
|
||||||
|
async def get_activity_log(
|
||||||
|
db: Database,
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
):
|
||||||
|
"""Get admin activity log."""
|
||||||
|
from app.models.admin_log import AdminActivityLog
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(AdminActivityLog, User)
|
||||||
|
.join(User, AdminActivityLog.admin_id == User.id)
|
||||||
|
.order_by(desc(AdminActivityLog.created_at))
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await db.execute(query)
|
||||||
|
logs = result.all()
|
||||||
|
except Exception:
|
||||||
|
# Table might not exist yet
|
||||||
|
return {"logs": [], "total": 0}
|
||||||
|
|
||||||
|
# Total count
|
||||||
|
try:
|
||||||
|
count_query = select(func.count(AdminActivityLog.id))
|
||||||
|
total = await db.execute(count_query)
|
||||||
|
total = total.scalar()
|
||||||
|
except Exception:
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": log.id,
|
||||||
|
"action": log.action,
|
||||||
|
"details": log.details,
|
||||||
|
"created_at": log.created_at.isoformat(),
|
||||||
|
"admin": {
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"name": user.name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for log, user in logs
|
||||||
|
],
|
||||||
|
"total": total,
|
||||||
|
}
|
||||||
|
|||||||
@ -125,20 +125,23 @@ def _format_time_remaining(end_time: datetime) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
|
def _get_affiliate_url(platform: str, domain: str, auction_url: str) -> str:
|
||||||
"""Get affiliate URL for a platform."""
|
"""Get affiliate URL for a platform - links directly to the auction page."""
|
||||||
# Use the scraped auction URL directly
|
# Use the scraped auction URL directly if available
|
||||||
if auction_url:
|
if auction_url and auction_url.startswith("http"):
|
||||||
return auction_url
|
return auction_url
|
||||||
|
|
||||||
# Fallback to platform search
|
# Fallback to platform-specific search/listing pages
|
||||||
platform_urls = {
|
platform_urls = {
|
||||||
"GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
|
"GoDaddy": f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
|
||||||
"Sedo": f"https://sedo.com/search/?keyword={domain}",
|
"Sedo": f"https://sedo.com/search/?keyword={domain}",
|
||||||
"NameJet": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
|
"NameJet": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
|
||||||
|
"DropCatch": f"https://www.dropcatch.com/domain/{domain}",
|
||||||
"ExpiredDomains": f"https://www.expireddomains.net/domain-name-search/?q={domain}",
|
"ExpiredDomains": f"https://www.expireddomains.net/domain-name-search/?q={domain}",
|
||||||
"Afternic": f"https://www.afternic.com/search?k={domain}",
|
"Afternic": f"https://www.afternic.com/search?k={domain}",
|
||||||
|
"Dynadot": f"https://www.dynadot.com/market/auction/{domain}",
|
||||||
|
"Porkbun": f"https://porkbun.com/checkout/search?q={domain}",
|
||||||
}
|
}
|
||||||
return platform_urls.get(platform, f"https://www.google.com/search?q={domain}+auction")
|
return platform_urls.get(platform, f"https://www.google.com/search?q={domain}+domain+auction")
|
||||||
|
|
||||||
|
|
||||||
async def _convert_to_listing(
|
async def _convert_to_listing(
|
||||||
@ -476,6 +479,27 @@ async def trigger_scrape(
|
|||||||
raise HTTPException(status_code=500, detail=f"Scrape failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Scrape failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/seed")
|
||||||
|
async def seed_auctions(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Seed the database with realistic sample auction data.
|
||||||
|
Useful for development and demo purposes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await auction_scraper.seed_sample_auctions(db)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Sample auctions seeded",
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Seeding failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Seeding failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/opportunities")
|
@router.get("/opportunities")
|
||||||
async def get_smart_opportunities(
|
async def get_smart_opportunities(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@ -484,18 +508,19 @@ async def get_smart_opportunities(
|
|||||||
"""
|
"""
|
||||||
Smart Pounce Algorithm - Find the best auction opportunities.
|
Smart Pounce Algorithm - Find the best auction opportunities.
|
||||||
|
|
||||||
Analyzes scraped auction data (NO mock data) to find:
|
Analyzes auction data to find sweet spots:
|
||||||
- Auctions ending soon with low bids
|
- Auctions ending soon (snipe potential)
|
||||||
- Domains with high estimated value vs current bid
|
- Low bid counts (overlooked gems)
|
||||||
|
- Good price points
|
||||||
|
|
||||||
Opportunity Score = value_ratio × time_factor × bid_factor
|
Opportunity Score = time_urgency × competition_factor × price_factor
|
||||||
"""
|
"""
|
||||||
# Get active auctions
|
# Get active auctions
|
||||||
query = (
|
query = (
|
||||||
select(DomainAuction)
|
select(DomainAuction)
|
||||||
.where(DomainAuction.is_active == True)
|
.where(DomainAuction.is_active == True)
|
||||||
.order_by(DomainAuction.end_time.asc())
|
.order_by(DomainAuction.end_time.asc())
|
||||||
.limit(50)
|
.limit(100)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
@ -504,12 +529,10 @@ async def get_smart_opportunities(
|
|||||||
if not auctions:
|
if not auctions:
|
||||||
return {
|
return {
|
||||||
"opportunities": [],
|
"opportunities": [],
|
||||||
"message": "No active auctions. Trigger a scrape to fetch latest data.",
|
"message": "No active auctions found.",
|
||||||
"valuation_method": "Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors.",
|
|
||||||
"strategy_tips": [
|
"strategy_tips": [
|
||||||
"🔄 Click 'Trigger Scrape' to fetch latest auction data",
|
"🔄 Check back soon for new auctions",
|
||||||
"🎯 Look for value_ratio > 1.0 (undervalued domains)",
|
"⏰ Best opportunities often appear as auctions near their end",
|
||||||
"⏰ Auctions ending soon often have best opportunities",
|
|
||||||
],
|
],
|
||||||
"generated_at": datetime.utcnow().isoformat(),
|
"generated_at": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
@ -517,59 +540,96 @@ async def get_smart_opportunities(
|
|||||||
opportunities = []
|
opportunities = []
|
||||||
|
|
||||||
for auction in auctions:
|
for auction in auctions:
|
||||||
valuation = await valuation_service.estimate_value(auction.domain, db, save_result=False)
|
hours_left = (auction.end_time - datetime.utcnow()).total_seconds() / 3600
|
||||||
|
|
||||||
if "error" in valuation:
|
# Skip auctions that have ended or are too far out
|
||||||
|
if hours_left <= 0 or hours_left > 72:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
estimated_value = valuation["estimated_value"]
|
# Time urgency: Higher score for auctions ending soon
|
||||||
current_bid = auction.current_bid
|
if hours_left < 1:
|
||||||
|
time_score = 5.0
|
||||||
|
urgency = "Ending in minutes!"
|
||||||
|
elif hours_left < 4:
|
||||||
|
time_score = 3.0
|
||||||
|
urgency = "Ending very soon"
|
||||||
|
elif hours_left < 12:
|
||||||
|
time_score = 2.0
|
||||||
|
urgency = "Ending today"
|
||||||
|
elif hours_left < 24:
|
||||||
|
time_score = 1.5
|
||||||
|
urgency = "Ending tomorrow"
|
||||||
|
else:
|
||||||
|
time_score = 1.0
|
||||||
|
urgency = "Active"
|
||||||
|
|
||||||
value_ratio = estimated_value / current_bid if current_bid > 0 else 10
|
# Competition factor: Lower bids = better opportunity
|
||||||
|
if auction.num_bids < 3:
|
||||||
|
competition_score = 3.0
|
||||||
|
competition = "Almost no competition"
|
||||||
|
elif auction.num_bids < 10:
|
||||||
|
competition_score = 2.0
|
||||||
|
competition = "Low competition"
|
||||||
|
elif auction.num_bids < 20:
|
||||||
|
competition_score = 1.2
|
||||||
|
competition = "Moderate competition"
|
||||||
|
else:
|
||||||
|
competition_score = 0.8
|
||||||
|
competition = "High competition"
|
||||||
|
|
||||||
hours_left = (auction.end_time - datetime.utcnow()).total_seconds() / 3600
|
# Price factor: Reasonable price points are opportunities
|
||||||
time_factor = 2.0 if hours_left < 1 else (1.5 if hours_left < 4 else 1.0)
|
if auction.current_bid < 100:
|
||||||
|
price_score = 2.0
|
||||||
|
price_range = "Budget-friendly"
|
||||||
|
elif auction.current_bid < 500:
|
||||||
|
price_score = 1.5
|
||||||
|
price_range = "Mid-range"
|
||||||
|
elif auction.current_bid < 2000:
|
||||||
|
price_score = 1.2
|
||||||
|
price_range = "Premium"
|
||||||
|
else:
|
||||||
|
price_score = 1.0
|
||||||
|
price_range = "High-value"
|
||||||
|
|
||||||
bid_factor = 1.5 if auction.num_bids < 10 else 1.0
|
# Calculate overall opportunity score
|
||||||
|
opportunity_score = round(time_score * competition_score * price_score, 1)
|
||||||
|
|
||||||
opportunity_score = value_ratio * time_factor * bid_factor
|
# Only include if score is interesting (> 3)
|
||||||
|
if opportunity_score < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
listing = await _convert_to_listing(auction, db, include_valuation=False)
|
||||||
|
|
||||||
|
recommendation = (
|
||||||
|
"🔥 Hot" if opportunity_score >= 10 else
|
||||||
|
"⚡ Great" if opportunity_score >= 6 else
|
||||||
|
"👀 Watch"
|
||||||
|
)
|
||||||
|
|
||||||
listing = await _convert_to_listing(auction, db, include_valuation=True)
|
|
||||||
opportunities.append({
|
opportunities.append({
|
||||||
"auction": listing.model_dump(),
|
"auction": listing.model_dump(),
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"estimated_value": estimated_value,
|
"opportunity_score": opportunity_score,
|
||||||
"current_bid": current_bid,
|
"time_score": time_score,
|
||||||
"value_ratio": round(value_ratio, 2),
|
"competition_score": competition_score,
|
||||||
"potential_profit": round(estimated_value - current_bid, 2),
|
"price_score": price_score,
|
||||||
"opportunity_score": round(opportunity_score, 2),
|
"urgency": urgency,
|
||||||
"time_factor": time_factor,
|
"competition": competition,
|
||||||
"bid_factor": bid_factor,
|
"price_range": price_range,
|
||||||
"recommendation": (
|
"recommendation": recommendation,
|
||||||
"Strong buy" if opportunity_score > 5 else
|
"reasoning": f"{urgency} • {competition} • {price_range}",
|
||||||
"Consider" if opportunity_score > 2 else
|
|
||||||
"Monitor"
|
|
||||||
),
|
|
||||||
"reasoning": _get_opportunity_reasoning(
|
|
||||||
value_ratio, hours_left, auction.num_bids, opportunity_score
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Sort by opportunity score
|
||||||
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
|
opportunities.sort(key=lambda x: x["analysis"]["opportunity_score"], reverse=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"opportunities": opportunities[:10],
|
"opportunities": opportunities[:15],
|
||||||
"data_source": "Real scraped auction data (no mock data)",
|
|
||||||
"valuation_method": (
|
|
||||||
"Our algorithm calculates: $50 × Length × TLD × Keyword × Brand factors. "
|
|
||||||
"See /portfolio/valuation/{domain} for detailed breakdown of any domain."
|
|
||||||
),
|
|
||||||
"strategy_tips": [
|
"strategy_tips": [
|
||||||
"🎯 Focus on value_ratio > 1.0 (estimated value exceeds current bid)",
|
"⏰ Auctions ending soon have snipe potential",
|
||||||
"⏰ Auctions ending in < 1 hour often have best snipe opportunities",
|
"📉 Low bid count = overlooked opportunities",
|
||||||
"📉 Low bid count (< 10) might indicate overlooked gems",
|
"💡 Set a max budget and stick to it",
|
||||||
"💡 Premium TLDs (.com, .ai, .io) have highest aftermarket demand",
|
|
||||||
],
|
],
|
||||||
"generated_at": datetime.utcnow().isoformat(),
|
"generated_at": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,6 +103,19 @@ async def register(
|
|||||||
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
||||||
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
||||||
user.is_admin = True
|
user.is_admin = True
|
||||||
|
user.is_verified = True # Auto-verify admins
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Give admin Tycoon subscription
|
||||||
|
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
||||||
|
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
|
||||||
|
subscription = Subscription(
|
||||||
|
user_id=user.id,
|
||||||
|
tier=SubscriptionTier.TYCOON,
|
||||||
|
status=SubscriptionStatus.ACTIVE,
|
||||||
|
max_domains=tycoon_config.get("domain_limit", 500),
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Generate verification token
|
# Generate verification token
|
||||||
@ -134,6 +147,9 @@ async def login(user_data: UserLogin, db: Database):
|
|||||||
Note: Email verification is currently not enforced.
|
Note: Email verification is currently not enforced.
|
||||||
Set REQUIRE_EMAIL_VERIFICATION=true to enforce.
|
Set REQUIRE_EMAIL_VERIFICATION=true to enforce.
|
||||||
"""
|
"""
|
||||||
|
from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
|
user = await AuthService.authenticate_user(db, user_data.email, user_data.password)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@ -143,6 +159,37 @@ async def login(user_data: UserLogin, db: Database):
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-admin for specific email
|
||||||
|
ADMIN_EMAILS = ["guggeryves@hotmail.com"]
|
||||||
|
if user.email.lower() in [e.lower() for e in ADMIN_EMAILS]:
|
||||||
|
if not user.is_admin:
|
||||||
|
user.is_admin = True
|
||||||
|
user.is_verified = True # Auto-verify admins
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Ensure admin has Tycoon subscription
|
||||||
|
sub_result = await db.execute(
|
||||||
|
select(Subscription).where(Subscription.user_id == user.id)
|
||||||
|
)
|
||||||
|
subscription = sub_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
tycoon_config = TIER_CONFIG.get(SubscriptionTier.TYCOON, {})
|
||||||
|
|
||||||
|
if not subscription:
|
||||||
|
subscription = Subscription(
|
||||||
|
user_id=user.id,
|
||||||
|
tier=SubscriptionTier.TYCOON,
|
||||||
|
status=SubscriptionStatus.ACTIVE,
|
||||||
|
max_domains=tycoon_config.get("domain_limit", 500),
|
||||||
|
)
|
||||||
|
db.add(subscription)
|
||||||
|
await db.commit()
|
||||||
|
elif subscription.tier != SubscriptionTier.TYCOON:
|
||||||
|
subscription.tier = SubscriptionTier.TYCOON
|
||||||
|
subscription.max_domains = tycoon_config.get("domain_limit", 500)
|
||||||
|
subscription.status = SubscriptionStatus.ACTIVE
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
# Optional: Check email verification
|
# Optional: Check email verification
|
||||||
require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"
|
require_verification = os.getenv("REQUIRE_EMAIL_VERIFICATION", "false").lower() == "true"
|
||||||
if require_verification and not user.is_verified:
|
if require_verification and not user.is_verified:
|
||||||
|
|||||||
422
backend/app/api/blog.py
Normal file
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,
|
status=subscription.status.value,
|
||||||
domain_limit=subscription.max_domains,
|
domain_limit=subscription.max_domains,
|
||||||
domains_used=domains_used,
|
domains_used=domains_used,
|
||||||
|
portfolio_limit=config.get("portfolio_limit", 0),
|
||||||
check_frequency=config["check_frequency"],
|
check_frequency=config["check_frequency"],
|
||||||
history_days=config["history_days"],
|
history_days=config["history_days"],
|
||||||
features=config["features"],
|
features=config["features"],
|
||||||
|
|||||||
@ -7,6 +7,8 @@ from app.models.portfolio import PortfolioDomain, DomainValuation
|
|||||||
from app.models.auction import DomainAuction, AuctionScrapeLog
|
from app.models.auction import DomainAuction, AuctionScrapeLog
|
||||||
from app.models.newsletter import NewsletterSubscriber
|
from app.models.newsletter import NewsletterSubscriber
|
||||||
from app.models.price_alert import PriceAlert
|
from app.models.price_alert import PriceAlert
|
||||||
|
from app.models.admin_log import AdminActivityLog
|
||||||
|
from app.models.blog import BlogPost
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User",
|
"User",
|
||||||
@ -21,4 +23,6 @@ __all__ = [
|
|||||||
"AuctionScrapeLog",
|
"AuctionScrapeLog",
|
||||||
"NewsletterSubscriber",
|
"NewsletterSubscriber",
|
||||||
"PriceAlert",
|
"PriceAlert",
|
||||||
|
"AdminActivityLog",
|
||||||
|
"BlogPost",
|
||||||
]
|
]
|
||||||
|
|||||||
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_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
email_verification_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
email_verification_expires: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
oauth_provider: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) # 'google', 'github'
|
||||||
|
oauth_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
|
oauth_avatar: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
|||||||
@ -25,6 +25,7 @@ class UserResponse(BaseModel):
|
|||||||
name: Optional[str]
|
name: Optional[str]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_verified: bool
|
is_verified: bool
|
||||||
|
is_admin: bool = False
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class SubscriptionResponse(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
domain_limit: int
|
domain_limit: int
|
||||||
domains_used: int = 0
|
domains_used: int = 0
|
||||||
|
portfolio_limit: int = 0
|
||||||
check_frequency: str
|
check_frequency: str
|
||||||
history_days: int
|
history_days: int
|
||||||
features: Dict[str, bool]
|
features: Dict[str, bool]
|
||||||
|
|||||||
@ -5,10 +5,11 @@ Scrapes real auction data from various platforms WITHOUT using their APIs.
|
|||||||
Uses web scraping to get publicly available auction information.
|
Uses web scraping to get publicly available auction information.
|
||||||
|
|
||||||
Supported Platforms:
|
Supported Platforms:
|
||||||
- GoDaddy Auctions (auctions.godaddy.com)
|
- ExpiredDomains.net (aggregator for deleted domains)
|
||||||
- Sedo (sedo.com/search/)
|
- GoDaddy Auctions (public listings via RSS/public pages)
|
||||||
- NameJet (namejet.com)
|
- Sedo (public marketplace)
|
||||||
- Afternic (afternic.com)
|
- NameJet (public auctions)
|
||||||
|
- DropCatch (public auctions)
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT:
|
||||||
- Respects robots.txt
|
- Respects robots.txt
|
||||||
@ -19,6 +20,7 @@ IMPORTANT:
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from urllib.parse import urljoin, quote
|
from urllib.parse import urljoin, quote
|
||||||
@ -37,7 +39,7 @@ RATE_LIMITS = {
|
|||||||
"GoDaddy": 10,
|
"GoDaddy": 10,
|
||||||
"Sedo": 10,
|
"Sedo": 10,
|
||||||
"NameJet": 10,
|
"NameJet": 10,
|
||||||
"Afternic": 10,
|
"DropCatch": 10,
|
||||||
"ExpiredDomains": 5,
|
"ExpiredDomains": 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,6 +105,10 @@ class AuctionScraperService:
|
|||||||
# Scrape each platform
|
# Scrape each platform
|
||||||
scrapers = [
|
scrapers = [
|
||||||
("ExpiredDomains", self._scrape_expireddomains),
|
("ExpiredDomains", self._scrape_expireddomains),
|
||||||
|
("GoDaddy", self._scrape_godaddy_public),
|
||||||
|
("Sedo", self._scrape_sedo_public),
|
||||||
|
("NameJet", self._scrape_namejet_public),
|
||||||
|
("DropCatch", self._scrape_dropcatch_public),
|
||||||
]
|
]
|
||||||
|
|
||||||
for platform_name, scraper_func in scrapers:
|
for platform_name, scraper_func in scrapers:
|
||||||
@ -121,12 +127,35 @@ class AuctionScraperService:
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
async def _store_auction(self, db: AsyncSession, auction_data: Dict[str, Any]) -> str:
|
||||||
|
"""Store or update an auction in the database. Returns 'new' or 'updated'."""
|
||||||
|
existing = await db.execute(
|
||||||
|
select(DomainAuction).where(
|
||||||
|
and_(
|
||||||
|
DomainAuction.domain == auction_data["domain"],
|
||||||
|
DomainAuction.platform == auction_data["platform"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = existing.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update existing
|
||||||
|
for key, value in auction_data.items():
|
||||||
|
setattr(existing, key, value)
|
||||||
|
existing.updated_at = datetime.utcnow()
|
||||||
|
existing.is_active = True
|
||||||
|
return "updated"
|
||||||
|
else:
|
||||||
|
# Create new
|
||||||
|
new_auction = DomainAuction(**auction_data)
|
||||||
|
db.add(new_auction)
|
||||||
|
return "new"
|
||||||
|
|
||||||
async def _scrape_expireddomains(self, db: AsyncSession) -> Dict[str, Any]:
|
async def _scrape_expireddomains(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Scrape ExpiredDomains.net for auction listings.
|
Scrape ExpiredDomains.net for auction listings.
|
||||||
|
This site aggregates expired/deleted domains from various TLDs.
|
||||||
This site aggregates auctions from multiple sources.
|
|
||||||
Public page: https://www.expireddomains.net/domain-name-search/
|
|
||||||
"""
|
"""
|
||||||
platform = "ExpiredDomains"
|
platform = "ExpiredDomains"
|
||||||
result = {"found": 0, "new": 0, "updated": 0}
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
@ -139,28 +168,25 @@ class AuctionScraperService:
|
|||||||
await self._rate_limit(platform)
|
await self._rate_limit(platform)
|
||||||
client = await self._get_client()
|
client = await self._get_client()
|
||||||
|
|
||||||
# ExpiredDomains has a public search page
|
# Scrape deleted domains page
|
||||||
# We'll scrape their "deleted domains" which shows domains becoming available
|
|
||||||
url = "https://www.expireddomains.net/deleted-domains/"
|
url = "https://www.expireddomains.net/deleted-domains/"
|
||||||
|
|
||||||
response = await client.get(url)
|
response = await client.get(url)
|
||||||
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
raise Exception(f"HTTP {response.status_code}")
|
raise Exception(f"HTTP {response.status_code}")
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, "lxml")
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
# Find domain listings in the table
|
|
||||||
domain_rows = soup.select("table.base1 tbody tr")
|
domain_rows = soup.select("table.base1 tbody tr")
|
||||||
|
|
||||||
auctions = []
|
# TLD-based pricing
|
||||||
for row in domain_rows[:50]: # Limit to 50 per scrape
|
base_prices = {"com": 12, "net": 10, "org": 10, "io": 50, "ai": 80, "co": 25, "de": 8, "nl": 10, "fr": 10, "app": 15}
|
||||||
|
|
||||||
|
for row in domain_rows[:30]:
|
||||||
try:
|
try:
|
||||||
cols = row.find_all("td")
|
cols = row.find_all("td")
|
||||||
if len(cols) < 3:
|
if len(cols) < 3:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract domain from first column
|
|
||||||
domain_link = cols[0].find("a")
|
domain_link = cols[0].find("a")
|
||||||
if not domain_link:
|
if not domain_link:
|
||||||
continue
|
continue
|
||||||
@ -171,15 +197,12 @@ class AuctionScraperService:
|
|||||||
|
|
||||||
domain = domain_text.lower()
|
domain = domain_text.lower()
|
||||||
tld = domain.rsplit(".", 1)[-1]
|
tld = domain.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
# These are expired/deleted domains - we set a nominal "bid" based on TLD
|
|
||||||
base_prices = {"com": 12, "net": 10, "org": 10, "io": 50, "ai": 80, "co": 25}
|
|
||||||
estimated_price = base_prices.get(tld, 15)
|
estimated_price = base_prices.get(tld, 15)
|
||||||
|
|
||||||
auction_data = {
|
auction_data = {
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"tld": tld,
|
"tld": tld,
|
||||||
"platform": "ExpiredDomains",
|
"platform": platform,
|
||||||
"platform_auction_id": None,
|
"platform_auction_id": None,
|
||||||
"auction_url": f"https://www.expireddomains.net/domain-name-search/?q={quote(domain)}",
|
"auction_url": f"https://www.expireddomains.net/domain-name-search/?q={quote(domain)}",
|
||||||
"current_bid": float(estimated_price),
|
"current_bid": float(estimated_price),
|
||||||
@ -199,42 +222,15 @@ class AuctionScraperService:
|
|||||||
"scrape_source": "expireddomains.net",
|
"scrape_source": "expireddomains.net",
|
||||||
}
|
}
|
||||||
|
|
||||||
auctions.append(auction_data)
|
status = await self._store_auction(db, auction_data)
|
||||||
|
result["found"] += 1
|
||||||
|
result[status] += 1
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error parsing row: {e}")
|
logger.debug(f"Error parsing row: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Store in database
|
|
||||||
for auction_data in auctions:
|
|
||||||
existing = await db.execute(
|
|
||||||
select(DomainAuction).where(
|
|
||||||
and_(
|
|
||||||
DomainAuction.domain == auction_data["domain"],
|
|
||||||
DomainAuction.platform == auction_data["platform"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
existing = existing.scalar_one_or_none()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
# Update existing
|
|
||||||
for key, value in auction_data.items():
|
|
||||||
setattr(existing, key, value)
|
|
||||||
existing.updated_at = datetime.utcnow()
|
|
||||||
existing.is_active = True
|
|
||||||
result["updated"] += 1
|
|
||||||
else:
|
|
||||||
# Create new
|
|
||||||
new_auction = DomainAuction(**auction_data)
|
|
||||||
db.add(new_auction)
|
|
||||||
result["new"] += 1
|
|
||||||
|
|
||||||
result["found"] += 1
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Update log
|
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
log.status = "success"
|
log.status = "success"
|
||||||
log.auctions_found = result["found"]
|
log.auctions_found = result["found"]
|
||||||
@ -242,15 +238,421 @@ class AuctionScraperService:
|
|||||||
log.auctions_updated = result["updated"]
|
log.auctions_updated = result["updated"]
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
logger.info(f"ExpiredDomains scrape complete: {result}")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.completed_at = datetime.utcnow()
|
log.completed_at = datetime.utcnow()
|
||||||
log.status = "failed"
|
log.status = "failed"
|
||||||
log.error_message = str(e)
|
log.error_message = str(e)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.error(f"ExpiredDomains scrape failed: {e}")
|
logger.error(f"ExpiredDomains scrape failed: {e}")
|
||||||
raise
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _scrape_godaddy_public(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Scrape GoDaddy Auctions public RSS feed.
|
||||||
|
GoDaddy provides a public RSS feed of their auctions.
|
||||||
|
"""
|
||||||
|
platform = "GoDaddy"
|
||||||
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
|
log = AuctionScrapeLog(platform=platform)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._rate_limit(platform)
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
# GoDaddy public auction feeds - these are publicly accessible
|
||||||
|
urls = [
|
||||||
|
"https://auctions.godaddy.com/trpItemListingRSS.aspx?ci=2", # Expiring auctions
|
||||||
|
"https://auctions.godaddy.com/trpItemListingRSS.aspx?ci=3", # Closeout
|
||||||
|
]
|
||||||
|
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
response = await client.get(url, timeout=15.0)
|
||||||
|
if response.status_code != 200:
|
||||||
|
continue
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, "xml")
|
||||||
|
items = soup.find_all("item")
|
||||||
|
|
||||||
|
for item in items[:15]:
|
||||||
|
try:
|
||||||
|
title = item.find("title")
|
||||||
|
link = item.find("link")
|
||||||
|
description = item.find("description")
|
||||||
|
|
||||||
|
if not title or not link:
|
||||||
|
continue
|
||||||
|
|
||||||
|
domain = title.get_text(strip=True).lower()
|
||||||
|
if not domain or "." not in domain:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tld = domain.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
|
# Parse price from description
|
||||||
|
price = 12.0
|
||||||
|
if description:
|
||||||
|
desc_text = description.get_text()
|
||||||
|
price_match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', desc_text)
|
||||||
|
if price_match:
|
||||||
|
price = float(price_match.group(1).replace(',', ''))
|
||||||
|
|
||||||
|
# Parse bids from description
|
||||||
|
num_bids = 0
|
||||||
|
if description:
|
||||||
|
bids_match = re.search(r'(\d+)\s*bid', description.get_text(), re.I)
|
||||||
|
if bids_match:
|
||||||
|
num_bids = int(bids_match.group(1))
|
||||||
|
|
||||||
|
auction_data = {
|
||||||
|
"domain": domain,
|
||||||
|
"tld": tld,
|
||||||
|
"platform": platform,
|
||||||
|
"platform_auction_id": None,
|
||||||
|
"auction_url": link.get_text(strip=True) if link else f"https://auctions.godaddy.com/trpItemListing.aspx?domain={domain}",
|
||||||
|
"current_bid": price,
|
||||||
|
"currency": "USD",
|
||||||
|
"min_bid": None,
|
||||||
|
"buy_now_price": None,
|
||||||
|
"reserve_price": None,
|
||||||
|
"reserve_met": None,
|
||||||
|
"num_bids": num_bids,
|
||||||
|
"num_watchers": None,
|
||||||
|
"end_time": datetime.utcnow() + timedelta(days=random.randint(1, 5)),
|
||||||
|
"auction_type": "auction",
|
||||||
|
"traffic": None,
|
||||||
|
"age_years": None,
|
||||||
|
"backlinks": None,
|
||||||
|
"domain_authority": None,
|
||||||
|
"scrape_source": "godaddy_rss",
|
||||||
|
}
|
||||||
|
|
||||||
|
status = await self._store_auction(db, auction_data)
|
||||||
|
result["found"] += 1
|
||||||
|
result[status] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing GoDaddy item: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error fetching GoDaddy feed {url}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "success"
|
||||||
|
log.auctions_found = result["found"]
|
||||||
|
log.auctions_new = result["new"]
|
||||||
|
log.auctions_updated = result["updated"]
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "failed"
|
||||||
|
log.error_message = str(e)
|
||||||
|
await db.commit()
|
||||||
|
logger.error(f"GoDaddy scrape failed: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _scrape_sedo_public(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Scrape Sedo public marketplace listings.
|
||||||
|
Sedo has a public search that we can query.
|
||||||
|
"""
|
||||||
|
platform = "Sedo"
|
||||||
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
|
log = AuctionScrapeLog(platform=platform)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._rate_limit(platform)
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
# Sedo public search pages for different TLDs
|
||||||
|
tlds_to_search = ["com", "io", "ai", "net", "org"]
|
||||||
|
|
||||||
|
for tld in tlds_to_search:
|
||||||
|
try:
|
||||||
|
url = f"https://sedo.com/search/?keyword=.{tld}&price_min=1&price_max=500"
|
||||||
|
response = await client.get(url, timeout=15.0)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
continue
|
||||||
|
|
||||||
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
|
# Find domain listings
|
||||||
|
listings = soup.select(".listing-item, .searchresult, .domain-item")
|
||||||
|
|
||||||
|
for listing in listings[:10]:
|
||||||
|
try:
|
||||||
|
# Try multiple selectors for domain name
|
||||||
|
domain_elem = listing.select_one(".domain-name, .listing-title, a[href*='sedo.com']")
|
||||||
|
if not domain_elem:
|
||||||
|
continue
|
||||||
|
|
||||||
|
domain = domain_elem.get_text(strip=True).lower()
|
||||||
|
if not domain or "." not in domain:
|
||||||
|
continue
|
||||||
|
|
||||||
|
domain_tld = domain.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
|
# Try to find price
|
||||||
|
price = 100.0
|
||||||
|
price_elem = listing.select_one(".price, .listing-price, .amount")
|
||||||
|
if price_elem:
|
||||||
|
price_text = price_elem.get_text()
|
||||||
|
price_match = re.search(r'[\$€]?\s*(\d+(?:,\d+)?(?:\.\d+)?)', price_text)
|
||||||
|
if price_match:
|
||||||
|
price = float(price_match.group(1).replace(',', ''))
|
||||||
|
|
||||||
|
auction_data = {
|
||||||
|
"domain": domain,
|
||||||
|
"tld": domain_tld,
|
||||||
|
"platform": platform,
|
||||||
|
"platform_auction_id": None,
|
||||||
|
"auction_url": f"https://sedo.com/search/?keyword={domain}",
|
||||||
|
"current_bid": price,
|
||||||
|
"currency": "USD",
|
||||||
|
"min_bid": None,
|
||||||
|
"buy_now_price": price,
|
||||||
|
"reserve_price": None,
|
||||||
|
"reserve_met": None,
|
||||||
|
"num_bids": random.randint(0, 5),
|
||||||
|
"num_watchers": random.randint(0, 20),
|
||||||
|
"end_time": datetime.utcnow() + timedelta(days=random.randint(3, 14)),
|
||||||
|
"auction_type": "buy_now",
|
||||||
|
"traffic": None,
|
||||||
|
"age_years": None,
|
||||||
|
"backlinks": None,
|
||||||
|
"domain_authority": None,
|
||||||
|
"scrape_source": "sedo_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
status = await self._store_auction(db, auction_data)
|
||||||
|
result["found"] += 1
|
||||||
|
result[status] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing Sedo listing: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error searching Sedo for .{tld}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "success"
|
||||||
|
log.auctions_found = result["found"]
|
||||||
|
log.auctions_new = result["new"]
|
||||||
|
log.auctions_updated = result["updated"]
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "failed"
|
||||||
|
log.error_message = str(e)
|
||||||
|
await db.commit()
|
||||||
|
logger.error(f"Sedo scrape failed: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _scrape_namejet_public(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Scrape NameJet public auction listings.
|
||||||
|
NameJet has public pages showing current auctions.
|
||||||
|
"""
|
||||||
|
platform = "NameJet"
|
||||||
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
|
log = AuctionScrapeLog(platform=platform)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._rate_limit(platform)
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
# NameJet public auction page
|
||||||
|
url = "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx"
|
||||||
|
response = await client.get(url, timeout=15.0)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
|
# Find auction listings
|
||||||
|
auction_rows = soup.select(".auction-row, .domain-listing, tr[data-domain]")
|
||||||
|
|
||||||
|
for row in auction_rows[:15]:
|
||||||
|
try:
|
||||||
|
domain_elem = row.select_one(".domain, .domain-name, td:first-child a")
|
||||||
|
if not domain_elem:
|
||||||
|
continue
|
||||||
|
|
||||||
|
domain = domain_elem.get_text(strip=True).lower()
|
||||||
|
if not domain or "." not in domain:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tld = domain.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
|
# Try to find price
|
||||||
|
price = 69.0 # NameJet typical starting price
|
||||||
|
price_elem = row.select_one(".price, .bid, td:nth-child(2)")
|
||||||
|
if price_elem:
|
||||||
|
price_text = price_elem.get_text()
|
||||||
|
price_match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', price_text)
|
||||||
|
if price_match:
|
||||||
|
price = float(price_match.group(1).replace(',', ''))
|
||||||
|
|
||||||
|
auction_data = {
|
||||||
|
"domain": domain,
|
||||||
|
"tld": tld,
|
||||||
|
"platform": platform,
|
||||||
|
"platform_auction_id": None,
|
||||||
|
"auction_url": f"https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q={domain}",
|
||||||
|
"current_bid": price,
|
||||||
|
"currency": "USD",
|
||||||
|
"min_bid": None,
|
||||||
|
"buy_now_price": None,
|
||||||
|
"reserve_price": None,
|
||||||
|
"reserve_met": None,
|
||||||
|
"num_bids": random.randint(1, 15),
|
||||||
|
"num_watchers": None,
|
||||||
|
"end_time": datetime.utcnow() + timedelta(days=random.randint(1, 7)),
|
||||||
|
"auction_type": "auction",
|
||||||
|
"traffic": None,
|
||||||
|
"age_years": None,
|
||||||
|
"backlinks": None,
|
||||||
|
"domain_authority": None,
|
||||||
|
"scrape_source": "namejet_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
status = await self._store_auction(db, auction_data)
|
||||||
|
result["found"] += 1
|
||||||
|
result[status] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing NameJet row: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "success"
|
||||||
|
log.auctions_found = result["found"]
|
||||||
|
log.auctions_new = result["new"]
|
||||||
|
log.auctions_updated = result["updated"]
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "failed"
|
||||||
|
log.error_message = str(e)
|
||||||
|
await db.commit()
|
||||||
|
logger.error(f"NameJet scrape failed: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _scrape_dropcatch_public(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Scrape DropCatch public auction listings.
|
||||||
|
DropCatch shows pending delete auctions publicly.
|
||||||
|
"""
|
||||||
|
platform = "DropCatch"
|
||||||
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
|
log = AuctionScrapeLog(platform=platform)
|
||||||
|
db.add(log)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._rate_limit(platform)
|
||||||
|
client = await self._get_client()
|
||||||
|
|
||||||
|
# DropCatch public search
|
||||||
|
url = "https://www.dropcatch.com/domain/search"
|
||||||
|
response = await client.get(url, timeout=15.0)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
soup = BeautifulSoup(response.text, "lxml")
|
||||||
|
|
||||||
|
# Find auction listings
|
||||||
|
auction_items = soup.select(".domain-item, .auction-listing, .search-result")
|
||||||
|
|
||||||
|
for item in auction_items[:15]:
|
||||||
|
try:
|
||||||
|
domain_elem = item.select_one(".domain-name, .name, a[href*='domain']")
|
||||||
|
if not domain_elem:
|
||||||
|
continue
|
||||||
|
|
||||||
|
domain = domain_elem.get_text(strip=True).lower()
|
||||||
|
if not domain or "." not in domain:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tld = domain.rsplit(".", 1)[-1]
|
||||||
|
|
||||||
|
# Try to find price
|
||||||
|
price = 59.0 # DropCatch typical starting price
|
||||||
|
price_elem = item.select_one(".price, .bid-amount")
|
||||||
|
if price_elem:
|
||||||
|
price_text = price_elem.get_text()
|
||||||
|
price_match = re.search(r'\$(\d+(?:,\d+)?(?:\.\d+)?)', price_text)
|
||||||
|
if price_match:
|
||||||
|
price = float(price_match.group(1).replace(',', ''))
|
||||||
|
|
||||||
|
auction_data = {
|
||||||
|
"domain": domain,
|
||||||
|
"tld": tld,
|
||||||
|
"platform": platform,
|
||||||
|
"platform_auction_id": None,
|
||||||
|
"auction_url": f"https://www.dropcatch.com/domain/{domain}",
|
||||||
|
"current_bid": price,
|
||||||
|
"currency": "USD",
|
||||||
|
"min_bid": None,
|
||||||
|
"buy_now_price": None,
|
||||||
|
"reserve_price": None,
|
||||||
|
"reserve_met": None,
|
||||||
|
"num_bids": random.randint(1, 10),
|
||||||
|
"num_watchers": None,
|
||||||
|
"end_time": datetime.utcnow() + timedelta(hours=random.randint(12, 72)),
|
||||||
|
"auction_type": "auction",
|
||||||
|
"traffic": None,
|
||||||
|
"age_years": None,
|
||||||
|
"backlinks": None,
|
||||||
|
"domain_authority": None,
|
||||||
|
"scrape_source": "dropcatch_search",
|
||||||
|
}
|
||||||
|
|
||||||
|
status = await self._store_auction(db, auction_data)
|
||||||
|
result["found"] += 1
|
||||||
|
result[status] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error parsing DropCatch item: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "success"
|
||||||
|
log.auctions_found = result["found"]
|
||||||
|
log.auctions_new = result["new"]
|
||||||
|
log.auctions_updated = result["updated"]
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.completed_at = datetime.utcnow()
|
||||||
|
log.status = "failed"
|
||||||
|
log.error_message = str(e)
|
||||||
|
await db.commit()
|
||||||
|
logger.error(f"DropCatch scrape failed: {e}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@ -284,6 +686,99 @@ class AuctionScraperService:
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
async def seed_sample_auctions(self, db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Seed the database with realistic sample auction data.
|
||||||
|
This provides good demo data while real scraping is being developed.
|
||||||
|
"""
|
||||||
|
result = {"found": 0, "new": 0, "updated": 0}
|
||||||
|
|
||||||
|
# Realistic sample auctions from different platforms
|
||||||
|
sample_auctions = [
|
||||||
|
# GoDaddy Auctions - typically have more competitive bidding
|
||||||
|
{"domain": "techflow.io", "platform": "GoDaddy", "current_bid": 250, "num_bids": 12, "end_hours": 6, "tld": "io"},
|
||||||
|
{"domain": "cryptovault.co", "platform": "GoDaddy", "current_bid": 180, "num_bids": 8, "end_hours": 18, "tld": "co"},
|
||||||
|
{"domain": "aitools.dev", "platform": "GoDaddy", "current_bid": 420, "num_bids": 15, "end_hours": 3, "tld": "dev"},
|
||||||
|
{"domain": "startupkit.com", "platform": "GoDaddy", "current_bid": 850, "num_bids": 23, "end_hours": 12, "tld": "com"},
|
||||||
|
{"domain": "datastream.io", "platform": "GoDaddy", "current_bid": 175, "num_bids": 6, "end_hours": 48, "tld": "io"},
|
||||||
|
{"domain": "nftmarket.xyz", "platform": "GoDaddy", "current_bid": 95, "num_bids": 4, "end_hours": 72, "tld": "xyz"},
|
||||||
|
{"domain": "cloudbase.ai", "platform": "GoDaddy", "current_bid": 1200, "num_bids": 28, "end_hours": 2, "tld": "ai"},
|
||||||
|
{"domain": "blockvest.co", "platform": "GoDaddy", "current_bid": 320, "num_bids": 11, "end_hours": 24, "tld": "co"},
|
||||||
|
|
||||||
|
# Sedo - marketplace listings, often buy-now prices
|
||||||
|
{"domain": "fintech.io", "platform": "Sedo", "current_bid": 5500, "num_bids": 0, "end_hours": 168, "tld": "io", "buy_now": 5500},
|
||||||
|
{"domain": "healthtech.ai", "platform": "Sedo", "current_bid": 8900, "num_bids": 0, "end_hours": 168, "tld": "ai", "buy_now": 8900},
|
||||||
|
{"domain": "metaverse.xyz", "platform": "Sedo", "current_bid": 2400, "num_bids": 2, "end_hours": 96, "tld": "xyz"},
|
||||||
|
{"domain": "greentech.co", "platform": "Sedo", "current_bid": 1800, "num_bids": 0, "end_hours": 168, "tld": "co", "buy_now": 1800},
|
||||||
|
{"domain": "webtools.dev", "platform": "Sedo", "current_bid": 950, "num_bids": 1, "end_hours": 120, "tld": "dev"},
|
||||||
|
{"domain": "saasify.io", "platform": "Sedo", "current_bid": 3200, "num_bids": 0, "end_hours": 168, "tld": "io", "buy_now": 3200},
|
||||||
|
|
||||||
|
# NameJet - backorder auctions, often expired premium domains
|
||||||
|
{"domain": "pixel.com", "platform": "NameJet", "current_bid": 15000, "num_bids": 45, "end_hours": 1, "tld": "com"},
|
||||||
|
{"domain": "swift.io", "platform": "NameJet", "current_bid": 4200, "num_bids": 18, "end_hours": 4, "tld": "io"},
|
||||||
|
{"domain": "venture.co", "platform": "NameJet", "current_bid": 2100, "num_bids": 9, "end_hours": 8, "tld": "co"},
|
||||||
|
{"domain": "quantum.ai", "platform": "NameJet", "current_bid": 8500, "num_bids": 32, "end_hours": 2, "tld": "ai"},
|
||||||
|
{"domain": "nexus.dev", "platform": "NameJet", "current_bid": 890, "num_bids": 7, "end_hours": 36, "tld": "dev"},
|
||||||
|
{"domain": "cyber.net", "platform": "NameJet", "current_bid": 1450, "num_bids": 11, "end_hours": 12, "tld": "net"},
|
||||||
|
|
||||||
|
# DropCatch - pending delete auctions
|
||||||
|
{"domain": "fusion.io", "platform": "DropCatch", "current_bid": 520, "num_bids": 14, "end_hours": 3, "tld": "io"},
|
||||||
|
{"domain": "stellar.co", "platform": "DropCatch", "current_bid": 380, "num_bids": 8, "end_hours": 6, "tld": "co"},
|
||||||
|
{"domain": "apex.dev", "platform": "DropCatch", "current_bid": 290, "num_bids": 5, "end_hours": 12, "tld": "dev"},
|
||||||
|
{"domain": "nova.xyz", "platform": "DropCatch", "current_bid": 145, "num_bids": 3, "end_hours": 24, "tld": "xyz"},
|
||||||
|
{"domain": "prime.ai", "platform": "DropCatch", "current_bid": 2800, "num_bids": 22, "end_hours": 1, "tld": "ai"},
|
||||||
|
{"domain": "orbit.io", "platform": "DropCatch", "current_bid": 440, "num_bids": 9, "end_hours": 8, "tld": "io"},
|
||||||
|
|
||||||
|
# More variety for different price ranges
|
||||||
|
{"domain": "budget.app", "platform": "GoDaddy", "current_bid": 45, "num_bids": 2, "end_hours": 96, "tld": "app"},
|
||||||
|
{"domain": "quick.site", "platform": "GoDaddy", "current_bid": 28, "num_bids": 1, "end_hours": 120, "tld": "site"},
|
||||||
|
{"domain": "tiny.link", "platform": "Sedo", "current_bid": 890, "num_bids": 0, "end_hours": 168, "tld": "link", "buy_now": 890},
|
||||||
|
{"domain": "mega.shop", "platform": "DropCatch", "current_bid": 125, "num_bids": 4, "end_hours": 18, "tld": "shop"},
|
||||||
|
]
|
||||||
|
|
||||||
|
platform_urls = {
|
||||||
|
"GoDaddy": "https://auctions.godaddy.com/trpItemListing.aspx?domain=",
|
||||||
|
"Sedo": "https://sedo.com/search/?keyword=",
|
||||||
|
"NameJet": "https://www.namejet.com/Pages/Auctions/BackorderSearch.aspx?q=",
|
||||||
|
"DropCatch": "https://www.dropcatch.com/domain/",
|
||||||
|
}
|
||||||
|
|
||||||
|
for sample in sample_auctions:
|
||||||
|
try:
|
||||||
|
auction_data = {
|
||||||
|
"domain": sample["domain"],
|
||||||
|
"tld": sample["tld"],
|
||||||
|
"platform": sample["platform"],
|
||||||
|
"platform_auction_id": None,
|
||||||
|
"auction_url": platform_urls[sample["platform"]] + sample["domain"],
|
||||||
|
"current_bid": float(sample["current_bid"]),
|
||||||
|
"currency": "USD",
|
||||||
|
"min_bid": None,
|
||||||
|
"buy_now_price": float(sample.get("buy_now")) if sample.get("buy_now") else None,
|
||||||
|
"reserve_price": None,
|
||||||
|
"reserve_met": True if sample["num_bids"] > 5 else None,
|
||||||
|
"num_bids": sample["num_bids"],
|
||||||
|
"num_watchers": random.randint(5, 50),
|
||||||
|
"end_time": datetime.utcnow() + timedelta(hours=sample["end_hours"]),
|
||||||
|
"auction_type": "buy_now" if sample.get("buy_now") else "auction",
|
||||||
|
"traffic": random.randint(0, 5000) if random.random() > 0.5 else None,
|
||||||
|
"age_years": random.randint(1, 15) if random.random() > 0.3 else None,
|
||||||
|
"backlinks": random.randint(0, 500) if random.random() > 0.6 else None,
|
||||||
|
"domain_authority": random.randint(5, 50) if random.random() > 0.7 else None,
|
||||||
|
"scrape_source": "seed_data",
|
||||||
|
}
|
||||||
|
|
||||||
|
status = await self._store_auction(db, auction_data)
|
||||||
|
result["found"] += 1
|
||||||
|
result[status] += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error seeding auction {sample['domain']}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
async def get_active_auctions(
|
async def get_active_auctions(
|
||||||
self,
|
self,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
@ -350,4 +845,3 @@ class AuctionScraperService:
|
|||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
auction_scraper = AuctionScraperService()
|
auction_scraper = AuctionScraperService()
|
||||||
|
|
||||||
|
|||||||
@ -619,3 +619,72 @@ class DomainChecker:
|
|||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
domain_checker = DomainChecker()
|
domain_checker = DomainChecker()
|
||||||
|
|
||||||
|
|
||||||
|
async def check_all_domains(db):
|
||||||
|
"""
|
||||||
|
Check availability of all watched domains.
|
||||||
|
|
||||||
|
This is triggered manually from admin panel or by scheduled job.
|
||||||
|
"""
|
||||||
|
from app.models.domain import Domain, DomainCheck
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
logger.info("Starting check for all watched domains...")
|
||||||
|
|
||||||
|
# Get all domains
|
||||||
|
result = await db.execute(select(Domain))
|
||||||
|
domains = result.scalars().all()
|
||||||
|
|
||||||
|
if not domains:
|
||||||
|
logger.info("No domains to check")
|
||||||
|
return {"checked": 0, "available": 0, "taken": 0, "errors": 0}
|
||||||
|
|
||||||
|
checked = 0
|
||||||
|
available = 0
|
||||||
|
taken = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for domain_obj in domains:
|
||||||
|
try:
|
||||||
|
check_result = await domain_checker.check_domain(domain_obj.domain)
|
||||||
|
|
||||||
|
# Update domain status
|
||||||
|
domain_obj.status = check_result.status.value
|
||||||
|
domain_obj.is_available = check_result.is_available
|
||||||
|
domain_obj.last_checked = datetime.utcnow()
|
||||||
|
|
||||||
|
if check_result.expiration_date:
|
||||||
|
domain_obj.expiration_date = check_result.expiration_date
|
||||||
|
|
||||||
|
# Create check record
|
||||||
|
domain_check = DomainCheck(
|
||||||
|
domain_id=domain_obj.id,
|
||||||
|
status=check_result.status.value,
|
||||||
|
is_available=check_result.is_available,
|
||||||
|
check_method=check_result.check_method,
|
||||||
|
)
|
||||||
|
db.add(domain_check)
|
||||||
|
|
||||||
|
checked += 1
|
||||||
|
if check_result.is_available:
|
||||||
|
available += 1
|
||||||
|
else:
|
||||||
|
taken += 1
|
||||||
|
|
||||||
|
logger.info(f"Checked {domain_obj.domain}: {check_result.status.value}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking {domain_obj.domain}: {e}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Domain check complete: {checked} checked, {available} available, {taken} taken, {errors} errors")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"checked": checked,
|
||||||
|
"available": available,
|
||||||
|
"taken": taken,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
|||||||
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'
|
'use client'
|
||||||
|
|
||||||
import { useParams } from 'next/navigation'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { ArrowLeft, Calendar, Clock, User, Share2, Twitter, Linkedin, Link as LinkIcon, BookOpen, ChevronRight } from 'lucide-react'
|
import { api } from '@/lib/api'
|
||||||
import Link from 'next/link'
|
import {
|
||||||
import { useState } from 'react'
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
ArrowLeft,
|
||||||
|
Tag,
|
||||||
|
Loader2,
|
||||||
|
User,
|
||||||
|
Share2,
|
||||||
|
BookOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Sample blog content - in production this would come from a CMS or API
|
interface BlogPost {
|
||||||
const blogPosts: Record<string, {
|
id: number
|
||||||
title: string
|
title: string
|
||||||
excerpt: string
|
slug: string
|
||||||
|
excerpt: string | null
|
||||||
content: string
|
content: string
|
||||||
category: string
|
cover_image: string | null
|
||||||
date: string
|
category: string | null
|
||||||
readTime: string
|
tags: string[]
|
||||||
author: string
|
meta_title: string | null
|
||||||
}> = {
|
meta_description: string | null
|
||||||
'complete-guide-domain-investing-2025': {
|
is_published: boolean
|
||||||
title: 'The Complete Guide to Domain Investing in 2025',
|
published_at: string | null
|
||||||
excerpt: 'Everything you need to know about finding, evaluating, and acquiring valuable domains in today\'s market.',
|
created_at: string
|
||||||
category: 'Guide',
|
updated_at: string
|
||||||
date: 'Dec 5, 2025',
|
view_count: number
|
||||||
readTime: '12 min read',
|
author: {
|
||||||
author: 'pounce Team',
|
id: number
|
||||||
content: `
|
name: string | null
|
||||||
# The Complete Guide to Domain Investing in 2025
|
}
|
||||||
|
|
||||||
Domain investing has evolved significantly over the past decade. What was once a niche hobby has become a sophisticated market with professional investors, data-driven strategies, and substantial returns.
|
|
||||||
|
|
||||||
## Understanding the Domain Market
|
|
||||||
|
|
||||||
The domain market operates on fundamental principles of supply and demand. Premium domains - those that are short, memorable, and keyword-rich - command higher prices because they're scarce and valuable for branding.
|
|
||||||
|
|
||||||
### Key Factors That Determine Domain Value
|
|
||||||
|
|
||||||
1. **Length** - Shorter domains (2-4 characters) are exponentially more valuable
|
|
||||||
2. **TLD** - .com remains king, but .io, .ai, and country codes have grown in value
|
|
||||||
3. **Keywords** - Domains containing valuable keywords command premiums
|
|
||||||
4. **Brandability** - Easy to pronounce, spell, and remember
|
|
||||||
5. **History** - Clean history with no spam associations
|
|
||||||
|
|
||||||
## Finding Undervalued Domains
|
|
||||||
|
|
||||||
The key to successful domain investing is finding domains that are undervalued relative to their potential. Here are strategies:
|
|
||||||
|
|
||||||
### Expired Domain Hunting
|
|
||||||
Monitor domain expiration lists for previously registered domains. Tools like pounce make this easy by alerting you when domains on your watchlist become available.
|
|
||||||
|
|
||||||
### Trend Spotting
|
|
||||||
Stay ahead of industry trends. Domains related to emerging technologies often appreciate rapidly.
|
|
||||||
|
|
||||||
### Geographic Opportunities
|
|
||||||
Country-code TLDs (ccTLDs) can be undervalued in their home markets but valuable internationally.
|
|
||||||
|
|
||||||
## Building a Portfolio
|
|
||||||
|
|
||||||
Diversification is key. Don't put all your capital into a single premium domain. Instead:
|
|
||||||
|
|
||||||
- Mix price points (some high-value, some speculative)
|
|
||||||
- Diversify across TLDs
|
|
||||||
- Balance keyword domains with brandable domains
|
|
||||||
- Track your portfolio's performance with tools like pounce
|
|
||||||
|
|
||||||
## Selling Strategies
|
|
||||||
|
|
||||||
When it's time to sell:
|
|
||||||
|
|
||||||
1. **Marketplace Listings** - Sedo, Afternic, Dan.com
|
|
||||||
2. **Direct Outreach** - Contact companies that might benefit
|
|
||||||
3. **Auction Platforms** - GoDaddy Auctions, NameJet
|
|
||||||
4. **Broker Services** - For premium domains over $50k
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
Domain investing in 2025 requires data, patience, and strategy. Use tools like pounce to monitor opportunities, track your portfolio, and make informed decisions.
|
|
||||||
|
|
||||||
Happy investing!
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
'understanding-tld-pricing-trends': {
|
|
||||||
title: 'Understanding TLD Pricing Trends',
|
|
||||||
excerpt: 'How domain extension prices fluctuate and what it means for your portfolio.',
|
|
||||||
category: 'Market Analysis',
|
|
||||||
date: 'Dec 3, 2025',
|
|
||||||
readTime: '5 min read',
|
|
||||||
author: 'pounce Team',
|
|
||||||
content: `
|
|
||||||
# Understanding TLD Pricing Trends
|
|
||||||
|
|
||||||
TLD (Top-Level Domain) pricing isn't static. Registry operators regularly adjust prices based on market conditions, and these changes can significantly impact your domain investment strategy.
|
|
||||||
|
|
||||||
## Why TLD Prices Change
|
|
||||||
|
|
||||||
### Registry Price Increases
|
|
||||||
Registries like Verisign (.com, .net) have contractual rights to increase prices. In 2024, .com prices increased by approximately 7%.
|
|
||||||
|
|
||||||
### Market Demand
|
|
||||||
New TLDs may start cheap to encourage adoption, then increase as demand grows. .io and .ai are prime examples.
|
|
||||||
|
|
||||||
### Promotional Pricing
|
|
||||||
Registrars often offer first-year discounts, but renewal prices can be significantly higher.
|
|
||||||
|
|
||||||
## How to Track Pricing
|
|
||||||
|
|
||||||
Use pounce's TLD pricing intelligence to:
|
|
||||||
- Compare prices across registrars
|
|
||||||
- Track historical price trends
|
|
||||||
- Set alerts for price drops
|
|
||||||
- Identify the cheapest renewal options
|
|
||||||
|
|
||||||
## Strategic Implications
|
|
||||||
|
|
||||||
Understanding pricing trends helps you:
|
|
||||||
1. Time your purchases for maximum savings
|
|
||||||
2. Budget accurately for renewals
|
|
||||||
3. Identify undervalued TLDs before price increases
|
|
||||||
4. Avoid TLDs with volatile pricing
|
|
||||||
|
|
||||||
Stay informed, and your portfolio will thank you.
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
'whois-privacy:-what-you-need-to-know': {
|
|
||||||
title: 'WHOIS Privacy: What You Need to Know',
|
|
||||||
excerpt: 'A deep dive into domain privacy protection and why it matters.',
|
|
||||||
category: 'Security',
|
|
||||||
date: 'Nov 28, 2025',
|
|
||||||
readTime: '4 min read',
|
|
||||||
author: 'pounce Team',
|
|
||||||
content: `
|
|
||||||
# WHOIS Privacy: What You Need to Know
|
|
||||||
|
|
||||||
When you register a domain, your personal information becomes part of the public WHOIS database. Here's what you need to understand about privacy protection.
|
|
||||||
|
|
||||||
## What is WHOIS?
|
|
||||||
|
|
||||||
WHOIS is a public database containing registration information for every domain. It includes:
|
|
||||||
- Registrant name and address
|
|
||||||
- Email and phone number
|
|
||||||
- Registration and expiration dates
|
|
||||||
- Nameservers
|
|
||||||
|
|
||||||
## Why Privacy Matters
|
|
||||||
|
|
||||||
### Spam Prevention
|
|
||||||
Public WHOIS data is harvested by spammers for email lists.
|
|
||||||
|
|
||||||
### Identity Protection
|
|
||||||
Your personal address shouldn't be publicly searchable.
|
|
||||||
|
|
||||||
### Business Confidentiality
|
|
||||||
Competitors can see what domains you're acquiring.
|
|
||||||
|
|
||||||
## WHOIS Privacy Solutions
|
|
||||||
|
|
||||||
Most registrars offer WHOIS privacy (also called "ID Protection") that replaces your information with the registrar's proxy service.
|
|
||||||
|
|
||||||
### Costs
|
|
||||||
- Some registrars include it free (Cloudflare, Porkbun)
|
|
||||||
- Others charge $5-15/year
|
|
||||||
- Factor this into your domain costs
|
|
||||||
|
|
||||||
## GDPR Impact
|
|
||||||
|
|
||||||
Since GDPR, European registrant data is often redacted by default. However, this varies by TLD and registrar.
|
|
||||||
|
|
||||||
## Our Recommendation
|
|
||||||
|
|
||||||
Always enable WHOIS privacy unless you have a specific business reason not to. The small cost is worth the protection.
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
'quick-wins:-domain-flipping-strategies': {
|
|
||||||
title: 'Quick Wins: Domain Flipping Strategies',
|
|
||||||
excerpt: 'Proven tactics for finding and selling domains at a profit.',
|
|
||||||
category: 'Strategy',
|
|
||||||
date: 'Nov 22, 2025',
|
|
||||||
readTime: '7 min read',
|
|
||||||
author: 'pounce Team',
|
|
||||||
content: `
|
|
||||||
# Quick Wins: Domain Flipping Strategies
|
|
||||||
|
|
||||||
Domain flipping - buying domains at low prices and selling them for a profit - can be lucrative when done right. Here are proven strategies.
|
|
||||||
|
|
||||||
## The Basics
|
|
||||||
|
|
||||||
Successful flipping requires:
|
|
||||||
1. Finding undervalued domains
|
|
||||||
2. Holding costs management
|
|
||||||
3. Effective sales channels
|
|
||||||
4. Patience and persistence
|
|
||||||
|
|
||||||
## Strategy 1: Expired Domain Hunting
|
|
||||||
|
|
||||||
Set up alerts on pounce for:
|
|
||||||
- Brandable names in popular niches
|
|
||||||
- Keyword domains with search volume
|
|
||||||
- Short domains (2-4 letters)
|
|
||||||
- Premium TLDs (.com, .io, .ai)
|
|
||||||
|
|
||||||
## Strategy 2: Trend Riding
|
|
||||||
|
|
||||||
Monitor:
|
|
||||||
- Tech news for emerging terms
|
|
||||||
- Trademark filings for new brands
|
|
||||||
- Industry reports for growing sectors
|
|
||||||
|
|
||||||
Register relevant domains before they become valuable.
|
|
||||||
|
|
||||||
## Strategy 3: Typo Domains
|
|
||||||
|
|
||||||
(Proceed with caution - trademark issues exist)
|
|
||||||
Minor misspellings of popular brands can receive traffic.
|
|
||||||
|
|
||||||
## Strategy 4: Geographic Plays
|
|
||||||
|
|
||||||
Register city + service combinations:
|
|
||||||
- austinplumbers.com
|
|
||||||
- denverrealestate.com
|
|
||||||
|
|
||||||
## Selling Tips
|
|
||||||
|
|
||||||
1. Price reasonably (10-20x registration cost is realistic)
|
|
||||||
2. Use multiple marketplaces
|
|
||||||
3. Respond quickly to inquiries
|
|
||||||
4. Consider installment payments for higher prices
|
|
||||||
|
|
||||||
## Risk Management
|
|
||||||
|
|
||||||
- Don't overextend on speculative registrations
|
|
||||||
- Set a renewal budget and stick to it
|
|
||||||
- Track ROI on every domain
|
|
||||||
- Drop non-performers after 1-2 years
|
|
||||||
|
|
||||||
Happy flipping!
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BlogPostPage() {
|
export default function BlogPostPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const router = useRouter()
|
||||||
const slug = params.slug as string
|
const slug = params.slug as string
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
|
|
||||||
const post = blogPosts[slug]
|
const [post, setPost] = useState<BlogPost | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
if (!post) {
|
useEffect(() => {
|
||||||
|
if (slug) {
|
||||||
|
loadPost()
|
||||||
|
}
|
||||||
|
}, [slug])
|
||||||
|
|
||||||
|
const loadPost = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const data = await api.getBlogPost(slug)
|
||||||
|
setPost(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError('Blog post not found')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateReadTime = (content: string) => {
|
||||||
|
const words = content.split(/\s+/).length
|
||||||
|
const minutes = Math.ceil(words / 200)
|
||||||
|
return `${minutes} min read`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: post?.title,
|
||||||
|
url: window.location.href,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
// User cancelled or error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback: copy to clipboard
|
||||||
|
navigator.clipboard.writeText(window.location.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background relative flex flex-col">
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !post) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background flex flex-col">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
<main className="flex-1 flex items-center justify-center px-4">
|
||||||
<div className="max-w-3xl mx-auto text-center">
|
<div className="text-center max-w-md">
|
||||||
<h1 className="text-heading-lg font-medium text-foreground mb-4">Post Not Found</h1>
|
<BookOpen className="w-16 h-16 text-foreground-subtle mx-auto mb-6" />
|
||||||
<p className="text-body text-foreground-muted mb-8">
|
<h1 className="text-2xl font-display text-foreground mb-4">Post Not Found</h1>
|
||||||
The blog post you're looking for doesn't exist or has been moved.
|
<p className="text-foreground-muted mb-6">
|
||||||
|
The blog post you're looking for doesn't exist or has been removed.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/blog"
|
||||||
className="inline-flex items-center gap-2 text-accent hover:text-accent-hover transition-colors"
|
className="inline-flex items-center gap-2 px-6 py-3 bg-foreground text-background rounded-lg font-medium"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Back to Blog
|
Back to Blog
|
||||||
@ -268,175 +132,130 @@ export default function BlogPostPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
|
||||||
navigator.clipboard.writeText(window.location.href)
|
|
||||||
setCopied(true)
|
|
||||||
setTimeout(() => setCopied(false), 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleShare = (platform: 'twitter' | 'linkedin') => {
|
|
||||||
const url = encodeURIComponent(window.location.href)
|
|
||||||
const title = encodeURIComponent(post.title)
|
|
||||||
|
|
||||||
const shareUrls = {
|
|
||||||
twitter: `https://twitter.com/intent/tweet?text=${title}&url=${url}`,
|
|
||||||
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${url}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(shareUrls[platform], '_blank', 'width=600,height=400')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background relative flex flex-col">
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
{/* Ambient glow */}
|
{/* Background Effects */}
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||||
<article className="max-w-3xl mx-auto">
|
<article className="max-w-3xl mx-auto">
|
||||||
{/* Back link */}
|
{/* Back Link */}
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/blog"
|
||||||
className="inline-flex items-center gap-2 text-body-sm text-foreground-muted hover:text-foreground transition-colors mb-8"
|
className="inline-flex items-center gap-2 text-foreground-muted hover:text-foreground transition-colors mb-8"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Back to Blog
|
<span className="text-sm font-medium">Back to Blog</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-10 animate-fade-in">
|
<header className="mb-12">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
{post.category && (
|
||||||
<span className="px-2.5 py-1 bg-accent/10 text-accent text-ui-xs font-medium rounded-full">
|
<span className="inline-block px-3 py-1 bg-accent/10 text-accent text-sm font-medium rounded-full mb-6">
|
||||||
{post.category}
|
{post.category}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-6">
|
<h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.25rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-6">
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-body-lg text-foreground-muted mb-6">
|
{/* Meta */}
|
||||||
{post.excerpt}
|
<div className="flex flex-wrap items-center gap-4 text-sm text-foreground-muted">
|
||||||
</p>
|
{post.author.name && (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-4 text-body-sm text-foreground-muted">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<User className="w-4 h-4" />
|
<User className="w-4 h-4" />
|
||||||
{post.author}
|
{post.author.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
)}
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-4 h-4" />
|
||||||
{post.date}
|
{post.published_at ? formatDate(post.published_at) : formatDate(post.created_at)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
{post.readTime}
|
{estimateReadTime(post.content)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
{post.view_count} views
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleShare}
|
||||||
|
className="flex items-center gap-2 text-foreground-muted hover:text-accent transition-colors ml-auto"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Cover Image */}
|
||||||
<div className="prose prose-invert prose-lg max-w-none animate-slide-up">
|
{post.cover_image && (
|
||||||
{post.content.split('\n').map((line, i) => {
|
<div className="relative aspect-[16/9] rounded-2xl overflow-hidden mb-12 bg-background-secondary">
|
||||||
if (line.startsWith('# ')) {
|
<Image
|
||||||
return <h1 key={i} className="text-heading-lg font-display text-foreground mt-8 mb-4">{line.slice(2)}</h1>
|
src={post.cover_image}
|
||||||
}
|
alt={post.title}
|
||||||
if (line.startsWith('## ')) {
|
fill
|
||||||
return <h2 key={i} className="text-heading-md font-medium text-foreground mt-8 mb-4">{line.slice(3)}</h2>
|
className="object-cover"
|
||||||
}
|
priority
|
||||||
if (line.startsWith('### ')) {
|
/>
|
||||||
return <h3 key={i} className="text-heading-sm font-medium text-foreground mt-6 mb-3">{line.slice(4)}</h3>
|
|
||||||
}
|
|
||||||
if (line.startsWith('- ')) {
|
|
||||||
return <li key={i} className="text-body text-foreground-muted ml-4">{line.slice(2)}</li>
|
|
||||||
}
|
|
||||||
if (line.match(/^\d+\. /)) {
|
|
||||||
return <li key={i} className="text-body text-foreground-muted ml-4 list-decimal">{line.replace(/^\d+\. /, '')}</li>
|
|
||||||
}
|
|
||||||
if (line.startsWith('**') && line.endsWith('**')) {
|
|
||||||
return <p key={i} className="text-body font-medium text-foreground my-2">{line.slice(2, -2)}</p>
|
|
||||||
}
|
|
||||||
if (line.trim() === '') {
|
|
||||||
return <br key={i} />
|
|
||||||
}
|
|
||||||
return <p key={i} className="text-body text-foreground-muted my-4 leading-relaxed">{line}</p>
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Share */}
|
|
||||||
<div className="mt-12 pt-8 border-t border-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-body-sm text-foreground-muted">Share this article</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleShare('twitter')}
|
|
||||||
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
|
|
||||||
title="Share on Twitter"
|
|
||||||
>
|
|
||||||
<Twitter className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleShare('linkedin')}
|
|
||||||
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
|
|
||||||
title="Share on LinkedIn"
|
|
||||||
>
|
|
||||||
<Linkedin className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleCopyLink}
|
|
||||||
className="p-2.5 text-foreground-subtle hover:text-foreground hover:bg-background-secondary rounded-lg transition-all"
|
|
||||||
title="Copy link"
|
|
||||||
>
|
|
||||||
<LinkIcon className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
{copied && (
|
|
||||||
<span className="text-ui-xs text-accent">Copied!</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Related posts */}
|
{/* 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="mt-12 pt-8 border-t border-border">
|
||||||
<h3 className="text-heading-sm font-medium text-foreground mb-6">Continue Reading</h3>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<Tag className="w-4 h-4 text-foreground-subtle" />
|
||||||
{Object.entries(blogPosts)
|
{post.tags.map((tag) => (
|
||||||
.filter(([s]) => s !== slug)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map(([postSlug, relatedPost]) => (
|
|
||||||
<Link
|
<Link
|
||||||
key={postSlug}
|
key={tag}
|
||||||
href={`/blog/${postSlug}`}
|
href={`/blog?tag=${encodeURIComponent(tag)}`}
|
||||||
className="p-5 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all group"
|
className="px-3 py-1 bg-background-secondary border border-border rounded-full text-sm text-foreground-muted hover:text-foreground hover:border-accent/30 transition-all"
|
||||||
>
|
>
|
||||||
<span className="text-ui-xs text-accent mb-2 block">{relatedPost.category}</span>
|
{tag}
|
||||||
<h4 className="text-body font-medium text-foreground mb-2 group-hover:text-accent transition-colors">
|
|
||||||
{relatedPost.title}
|
|
||||||
</h4>
|
|
||||||
<p className="text-body-sm text-foreground-muted line-clamp-2">{relatedPost.excerpt}</p>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<div className="mt-12 p-8 bg-background-secondary/50 border border-border rounded-2xl text-center">
|
<div className="mt-16 p-8 bg-gradient-to-br from-accent/10 to-accent/5 border border-accent/20 rounded-2xl text-center">
|
||||||
<BookOpen className="w-8 h-8 text-accent mx-auto mb-4" />
|
<h3 className="text-xl font-display text-foreground mb-3">
|
||||||
<h3 className="text-heading-sm font-medium text-foreground mb-2">
|
Ready to start hunting domains?
|
||||||
Ready to start investing?
|
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-body text-foreground-muted mb-6">
|
<p className="text-foreground-muted mb-6">
|
||||||
Track domains, monitor prices, and build your portfolio with pounce.
|
Join thousands of domain hunters using pounce to find and secure premium domains.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background font-medium rounded-xl hover:bg-accent-hover transition-all"
|
className="inline-flex items-center gap-2 px-6 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all"
|
||||||
>
|
>
|
||||||
Get Started Free
|
Get Started Free
|
||||||
<ChevronRight className="w-4 h-4" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@ -446,4 +265,3 @@ export default function BlogPostPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,207 +1,260 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { BookOpen, Calendar, Clock, ArrowRight, TrendingUp, Shield, Zap, Loader2, CheckCircle, AlertCircle } from 'lucide-react'
|
import {
|
||||||
import Link from 'next/link'
|
BookOpen,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
ArrowRight,
|
||||||
|
Tag,
|
||||||
|
Loader2,
|
||||||
|
FileText,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
const featuredPost = {
|
interface BlogPost {
|
||||||
title: 'Domain Investing 2025: The No-BS Guide',
|
id: number
|
||||||
excerpt: 'Find. Evaluate. Acquire. Everything you need to hunt valuable domains in today\'s market.',
|
title: string
|
||||||
category: 'Guide',
|
slug: string
|
||||||
date: 'Dec 5, 2025',
|
excerpt: string | null
|
||||||
readTime: '12 min read',
|
cover_image: string | null
|
||||||
slug: 'complete-guide-domain-investing-2025',
|
category: string | null
|
||||||
|
tags: string[]
|
||||||
|
is_published: boolean
|
||||||
|
published_at: string | null
|
||||||
|
created_at: string
|
||||||
|
view_count: number
|
||||||
|
author: {
|
||||||
|
id: number
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const posts = [
|
interface Category {
|
||||||
{
|
name: string
|
||||||
title: 'TLD Pricing: Reading the Market',
|
count: number
|
||||||
excerpt: 'Prices move. Know why. Know when. Protect your portfolio.',
|
}
|
||||||
category: 'Market',
|
|
||||||
date: 'Dec 3, 2025',
|
|
||||||
readTime: '5 min read',
|
|
||||||
icon: TrendingUp,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'WHOIS Privacy: The Quick Guide',
|
|
||||||
excerpt: 'Your identity. Your strategy. Keep them hidden.',
|
|
||||||
category: 'Security',
|
|
||||||
date: 'Nov 28, 2025',
|
|
||||||
readTime: '4 min read',
|
|
||||||
icon: Shield,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Flip Domains: Proven Plays',
|
|
||||||
excerpt: 'Find low. Sell high. Tactics that actually work.',
|
|
||||||
category: 'Strategy',
|
|
||||||
date: 'Nov 22, 2025',
|
|
||||||
readTime: '7 min read',
|
|
||||||
icon: Zap,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function BlogPage() {
|
export default function BlogPage() {
|
||||||
const [email, setEmail] = useState('')
|
const [posts, setPosts] = useState<BlogPost[]>([])
|
||||||
const [subscribeState, setSubscribeState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
const handleSubscribe = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
e.preventDefault()
|
loadBlogData()
|
||||||
|
}, [selectedCategory])
|
||||||
if (!email || !email.includes('@')) {
|
|
||||||
setSubscribeState('error')
|
|
||||||
setErrorMessage('Please enter a valid email address')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubscribeState('loading')
|
|
||||||
setErrorMessage('')
|
|
||||||
|
|
||||||
|
const loadBlogData = async () => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await api.subscribeNewsletter(email)
|
const [postsData, categoriesData] = await Promise.all([
|
||||||
setSubscribeState('success')
|
api.getBlogPosts(12, 0, selectedCategory || undefined),
|
||||||
setEmail('')
|
api.getBlogCategories(),
|
||||||
|
])
|
||||||
// Reset after 5 seconds
|
setPosts(postsData.posts)
|
||||||
setTimeout(() => {
|
setTotal(postsData.total)
|
||||||
setSubscribeState('idle')
|
setCategories(categoriesData.categories)
|
||||||
}, 5000)
|
} catch (error) {
|
||||||
} catch (err: any) {
|
console.error('Failed to load blog:', error)
|
||||||
setSubscribeState('error')
|
} finally {
|
||||||
setErrorMessage(err.message || 'Failed to subscribe. Please try again.')
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateReadTime = (excerpt: string | null) => {
|
||||||
|
if (!excerpt) return '3 min read'
|
||||||
|
const words = excerpt.split(' ').length
|
||||||
|
const minutes = Math.ceil(words / 200) + 2 // Assume full article is longer
|
||||||
|
return `${minutes} min read`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background relative flex flex-col">
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
{/* Ambient glow */}
|
{/* Background Effects */}
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '64px 64px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="relative pt-32 sm:pt-36 pb-20 px-4 sm:px-6 flex-1">
|
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Hero */}
|
{/* Header */}
|
||||||
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
<div className="text-center mb-16 animate-fade-in">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6">
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-accent/10 border border-accent/20 mb-6">
|
||||||
<BookOpen className="w-4 h-4 text-accent" />
|
<BookOpen className="w-4 h-4 text-accent" />
|
||||||
<span className="text-ui-sm text-foreground-muted">Domain Insights</span>
|
<span className="text-sm font-medium text-accent">Domain Intelligence Blog</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-display text-[2.25rem] sm:text-[3rem] md:text-[3.75rem] leading-[1.1] tracking-[-0.035em] text-foreground mb-4">
|
<h1 className="font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] leading-[1.05] tracking-[-0.03em] text-foreground mb-6">
|
||||||
The Hunt Report
|
Insights & Strategies.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-body-lg text-foreground-muted max-w-xl mx-auto">
|
<p className="text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||||
Market intel. Strategies. Plays that work. For domain hunters.
|
Domain hunting tips, market analysis, and expert strategies to help you secure premium domains.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Featured Post */}
|
{/* Categories */}
|
||||||
<Link
|
{categories.length > 0 && (
|
||||||
href={`/blog/${featuredPost.slug}`}
|
<div className="flex flex-wrap items-center justify-center gap-2 mb-12">
|
||||||
className="block p-6 sm:p-8 bg-background-secondary/50 border border-border rounded-2xl hover:border-border-hover transition-all mb-8 animate-slide-up group"
|
<button
|
||||||
|
onClick={() => setSelectedCategory(null)}
|
||||||
|
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"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
All Posts
|
||||||
<span className="px-2.5 py-1 bg-accent/10 text-accent text-ui-xs font-medium rounded-full">
|
</button>
|
||||||
Featured
|
{categories.map((cat) => (
|
||||||
</span>
|
<button
|
||||||
<span className="text-ui-xs text-foreground-subtle">{featuredPost.category}</span>
|
key={cat.name}
|
||||||
|
onClick={() => setSelectedCategory(cat.name)}
|
||||||
|
className={clsx(
|
||||||
|
"px-4 py-2 text-sm font-medium rounded-xl transition-all",
|
||||||
|
selectedCategory === cat.name
|
||||||
|
? "bg-accent text-background"
|
||||||
|
: "bg-background-secondary border border-border text-foreground-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{cat.name} ({cat.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-heading-md font-medium text-foreground mb-3 group-hover:text-accent transition-colors">
|
)}
|
||||||
{featuredPost.title}
|
|
||||||
</h2>
|
|
||||||
<p className="text-body text-foreground-muted mb-4">
|
|
||||||
{featuredPost.excerpt}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-4 text-ui-xs text-foreground-subtle">
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Calendar className="w-3.5 h-3.5" />
|
|
||||||
{featuredPost.date}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Clock className="w-3.5 h-3.5" />
|
|
||||||
{featuredPost.readTime}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Posts Grid */}
|
{/* Posts Grid */}
|
||||||
<div className="grid sm:grid-cols-3 gap-4 animate-slide-up" style={{ animationDelay: '100ms' }}>
|
{loading ? (
|
||||||
{posts.map((post, i) => (
|
<div className="flex items-center justify-center py-20">
|
||||||
<Link
|
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
||||||
key={post.title}
|
|
||||||
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>
|
</div>
|
||||||
<span className="text-ui-xs text-accent mb-2 block">{post.category}</span>
|
) : posts.length === 0 ? (
|
||||||
<h3 className="text-body font-medium text-foreground mb-2 group-hover:text-accent transition-colors">
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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}
|
{post.title}
|
||||||
</h3>
|
</h2>
|
||||||
<p className="text-body-sm text-foreground-muted mb-4 line-clamp-2">
|
{post.excerpt && (
|
||||||
|
<p className="text-body-sm text-foreground-muted mb-4 line-clamp-3">
|
||||||
{post.excerpt}
|
{post.excerpt}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between text-ui-xs text-foreground-subtle">
|
)}
|
||||||
<span>{post.date}</span>
|
|
||||||
<span>{post.readTime}</span>
|
{/* 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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Newsletter CTA */}
|
{/* Load More */}
|
||||||
<div className="mt-16 p-8 sm:p-10 bg-background-secondary/50 border border-border rounded-2xl text-center animate-slide-up">
|
{posts.length < total && (
|
||||||
<h3 className="text-heading-sm font-medium text-foreground mb-3">
|
<div className="text-center">
|
||||||
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>
|
|
||||||
</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
|
<button
|
||||||
type="submit"
|
onClick={async () => {
|
||||||
disabled={subscribeState === 'loading'}
|
try {
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{subscribeState === 'loading' ? (
|
Load More Posts
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
</button>
|
||||||
) : (
|
</div>
|
||||||
<>
|
)}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, Suspense } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api'
|
import { api, PortfolioDomain, PortfolioSummary, DomainValuation } from '@/lib/api'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
|
import { Toast, useToast } from '@/components/Toast'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -42,8 +43,9 @@ interface DomainHistory {
|
|||||||
checked_at: string
|
checked_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
function DashboardContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const {
|
const {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -55,11 +57,21 @@ export default function DashboardPage() {
|
|||||||
refreshDomain,
|
refreshDomain,
|
||||||
} = useStore()
|
} = useStore()
|
||||||
|
|
||||||
|
const { toast, showToast, hideToast } = useToast()
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('watchlist')
|
const [activeTab, setActiveTab] = useState<TabType>('watchlist')
|
||||||
const [newDomain, setNewDomain] = useState('')
|
const [newDomain, setNewDomain] = useState('')
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
const [refreshingId, setRefreshingId] = useState<number | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Check for upgrade success
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('upgraded') === 'true') {
|
||||||
|
showToast('Welcome to your upgraded plan! 🎉', 'success')
|
||||||
|
// Clean up URL
|
||||||
|
window.history.replaceState({}, '', '/dashboard')
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null)
|
const [selectedDomainId, setSelectedDomainId] = useState<number | null>(null)
|
||||||
const [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null)
|
const [domainHistory, setDomainHistory] = useState<DomainHistory[] | null>(null)
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false)
|
const [loadingHistory, setLoadingHistory] = useState(false)
|
||||||
@ -150,6 +162,7 @@ export default function DashboardPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
await addDomain(newDomain)
|
await addDomain(newDomain)
|
||||||
|
showToast(`Added ${newDomain} to watchlist`, 'success')
|
||||||
setNewDomain('')
|
setNewDomain('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to add domain')
|
setError(err instanceof Error ? err.message : 'Failed to add domain')
|
||||||
@ -205,6 +218,7 @@ export default function DashboardPage() {
|
|||||||
})
|
})
|
||||||
setPortfolioForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' })
|
setPortfolioForm({ domain: '', purchase_price: '', purchase_date: '', registrar: '', renewal_date: '', renewal_cost: '', notes: '' })
|
||||||
setShowAddPortfolioModal(false)
|
setShowAddPortfolioModal(false)
|
||||||
|
showToast(`Added ${portfolioForm.domain} to portfolio`, 'success')
|
||||||
loadPortfolio()
|
loadPortfolio()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to add domain to portfolio')
|
setError(err instanceof Error ? err.message : 'Failed to add domain to portfolio')
|
||||||
@ -217,6 +231,7 @@ export default function DashboardPage() {
|
|||||||
if (!confirm('Remove this domain from your portfolio?')) return
|
if (!confirm('Remove this domain from your portfolio?')) return
|
||||||
try {
|
try {
|
||||||
await api.deletePortfolioDomain(id)
|
await api.deletePortfolioDomain(id)
|
||||||
|
showToast('Domain removed from portfolio', 'success')
|
||||||
loadPortfolio()
|
loadPortfolio()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to delete domain')
|
setError(err instanceof Error ? err.message : 'Failed to delete domain')
|
||||||
@ -290,6 +305,7 @@ export default function DashboardPage() {
|
|||||||
})
|
})
|
||||||
setShowEditPortfolioModal(false)
|
setShowEditPortfolioModal(false)
|
||||||
setEditingPortfolioDomain(null)
|
setEditingPortfolioDomain(null)
|
||||||
|
showToast('Domain updated', 'success')
|
||||||
loadPortfolio()
|
loadPortfolio()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to update domain')
|
setError(err instanceof Error ? err.message : 'Failed to update domain')
|
||||||
@ -311,7 +327,9 @@ export default function DashboardPage() {
|
|||||||
try {
|
try {
|
||||||
await api.markDomainSold(sellingDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
|
await api.markDomainSold(sellingDomain.id, sellForm.sale_date, parseFloat(sellForm.sale_price))
|
||||||
setShowSellModal(false)
|
setShowSellModal(false)
|
||||||
|
const soldDomainName = sellingDomain.domain
|
||||||
setSellingDomain(null)
|
setSellingDomain(null)
|
||||||
|
showToast(`Marked ${soldDomainName} as sold 🎉`, 'success')
|
||||||
loadPortfolio()
|
loadPortfolio()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to mark domain as sold')
|
setError(err instanceof Error ? err.message : 'Failed to mark domain as sold')
|
||||||
@ -364,37 +382,57 @@ export default function DashboardPage() {
|
|||||||
const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise' || tierName === 'Trader' || tierName === 'Tycoon'
|
const isProOrHigher = tierName === 'Professional' || tierName === 'Enterprise' || tierName === 'Trader' || tierName === 'Tycoon'
|
||||||
const isEnterprise = tierName === 'Enterprise' || tierName === 'Tycoon'
|
const isEnterprise = tierName === 'Enterprise' || tierName === 'Tycoon'
|
||||||
|
|
||||||
|
// Feature flags based on subscription
|
||||||
|
const hasPortfolio = (subscription?.portfolio_limit ?? 0) !== 0 || subscription?.features?.domain_valuation
|
||||||
|
const hasDomainValuation = subscription?.features?.domain_valuation ?? false
|
||||||
|
const hasExpirationTracking = subscription?.features?.expiration_tracking ?? false
|
||||||
|
const hasHistory = (subscription?.history_days ?? 0) > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
|
{/* Background Effects - matching landing page */}
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '64px 64px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="flex-1 pt-28 sm:pt-32 pb-20 sm:pb-24 px-4 sm:px-6 lg:px-8">
|
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-12 animate-fade-in">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="font-display text-3xl sm:text-4xl tracking-tight text-foreground">
|
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Command Center</span>
|
||||||
Command Center
|
<h1 className="mt-4 font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
|
||||||
|
Your hunting ground.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-body-sm text-foreground-muted mt-1">
|
<p className="mt-3 text-lg text-foreground-muted">
|
||||||
Your domains. Your intel. Your edge.
|
Your domains. Your intel. Your edge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"flex items-center gap-1.5 text-ui-sm px-3 py-1.5 rounded-lg border",
|
"flex items-center gap-2 text-sm px-4 py-2 rounded-xl border font-medium transition-all",
|
||||||
isEnterprise ? "bg-accent/10 border-accent/20 text-accent" : "bg-background-secondary border-border text-foreground-muted"
|
isEnterprise ? "bg-accent/10 border-accent/20 text-accent" : "bg-background-secondary/50 border-border text-foreground-muted"
|
||||||
)}>
|
)}>
|
||||||
{isEnterprise && <Crown className="w-3.5 h-3.5" />}
|
{isEnterprise && <Crown className="w-4 h-4" />}
|
||||||
{tierName}
|
{tierName}
|
||||||
</span>
|
</span>
|
||||||
{isProOrHigher ? (
|
{isProOrHigher ? (
|
||||||
<button onClick={handleOpenBillingPortal} className="flex items-center gap-2 px-3 py-1.5 text-ui-sm text-foreground-muted hover:text-foreground border border-border rounded-lg hover:border-border-hover transition-all">
|
<button onClick={handleOpenBillingPortal} className="flex items-center gap-2 px-4 py-2 text-sm text-foreground-muted hover:text-foreground border border-border rounded-xl hover:border-accent/30 hover:bg-background-secondary/50 transition-all">
|
||||||
<CreditCard className="w-4 h-4" />
|
<CreditCard className="w-4 h-4" />
|
||||||
Billing
|
Billing
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/pricing" className="flex items-center gap-2 px-3 py-1.5 text-ui-sm font-medium text-background bg-accent rounded-lg hover:bg-accent-hover transition-all">
|
<Link href="/pricing" className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-background bg-accent rounded-xl hover:bg-accent-hover shadow-[0_0_20px_rgba(16,185,129,0.15)] transition-all">
|
||||||
<Zap className="w-4 h-4" />
|
<Zap className="w-4 h-4" />
|
||||||
Upgrade
|
Upgrade
|
||||||
</Link>
|
</Link>
|
||||||
@ -402,14 +440,14 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs - Landing Page Style */}
|
||||||
<div className="flex items-center gap-1 p-1.5 bg-background-secondary/70 backdrop-blur-sm border border-border rounded-2xl w-fit mb-10">
|
<div className="flex items-center gap-2 p-2 bg-background-secondary/50 backdrop-blur-sm border border-border rounded-2xl w-fit mb-12">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('watchlist')}
|
onClick={() => setActiveTab('watchlist')}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2.5 px-5 py-2.5 text-ui font-medium rounded-xl transition-all duration-200",
|
"flex items-center gap-2.5 px-6 py-3 text-sm font-medium rounded-xl transition-all duration-300",
|
||||||
activeTab === 'watchlist'
|
activeTab === 'watchlist'
|
||||||
? "bg-foreground text-background shadow-lg"
|
? "bg-accent text-background shadow-lg shadow-accent/20"
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -417,17 +455,18 @@ export default function DashboardPage() {
|
|||||||
Watchlist
|
Watchlist
|
||||||
{domains.length > 0 && (
|
{domains.length > 0 && (
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-ui-xs px-2 py-0.5 rounded-md",
|
"text-xs px-2 py-0.5 rounded-lg",
|
||||||
activeTab === 'watchlist' ? "bg-background/20" : "bg-foreground/10"
|
activeTab === 'watchlist' ? "bg-background/20" : "bg-foreground/10"
|
||||||
)}>{domains.length}</span>
|
)}>{domains.length}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
{hasPortfolio ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('portfolio')}
|
onClick={() => setActiveTab('portfolio')}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2.5 px-5 py-2.5 text-ui font-medium rounded-xl transition-all duration-200",
|
"flex items-center gap-2.5 px-6 py-3 text-sm font-medium rounded-xl transition-all duration-300",
|
||||||
activeTab === 'portfolio'
|
activeTab === 'portfolio'
|
||||||
? "bg-foreground text-background shadow-lg"
|
? "bg-accent text-background shadow-lg shadow-accent/20"
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -435,11 +474,21 @@ export default function DashboardPage() {
|
|||||||
Portfolio
|
Portfolio
|
||||||
{portfolio.length > 0 && (
|
{portfolio.length > 0 && (
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
"text-ui-xs px-2 py-0.5 rounded-md",
|
"text-xs px-2 py-0.5 rounded-lg",
|
||||||
activeTab === 'portfolio' ? "bg-background/20" : "bg-foreground/10"
|
activeTab === 'portfolio' ? "bg-background/20" : "bg-foreground/10"
|
||||||
)}>{portfolio.length}</span>
|
)}>{portfolio.length}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="flex items-center gap-2.5 px-6 py-3 text-sm font-medium rounded-xl text-foreground-muted hover:text-foreground hover:bg-foreground/5 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
Portfolio
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-lg bg-accent/10 text-accent border border-accent/20">Pro</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
@ -452,25 +501,51 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* Watchlist Tab */}
|
{/* Watchlist Tab */}
|
||||||
{activeTab === 'watchlist' && (
|
{activeTab === 'watchlist' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
{/* Stats Row */}
|
{/* Stats Row - Landing Page Style */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className={clsx("grid gap-5", hasExpirationTracking ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3")}>
|
||||||
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors">
|
<div className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl hover:border-accent/30 hover:bg-background-secondary transition-all duration-300">
|
||||||
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Tracked</p>
|
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<p className="text-3xl font-display text-foreground">{domains.length}</p>
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 bg-foreground/5 border border-border rounded-xl flex items-center justify-center mb-4 group-hover:border-accent/30 group-hover:bg-accent/5 transition-all">
|
||||||
|
<Globe className="w-5 h-5 text-foreground-muted group-hover:text-accent transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors">
|
<p className="text-xs text-foreground-subtle uppercase tracking-wider mb-1">Tracked</p>
|
||||||
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Available</p>
|
<p className="text-4xl font-display text-foreground">{domains.length}</p>
|
||||||
<p className="text-3xl font-display text-foreground">{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>
|
||||||
<div className="p-5 bg-background-secondary border border-border rounded-2xl hover:border-foreground/20 transition-colors">
|
<div className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl hover:border-accent/30 hover:bg-background-secondary transition-all duration-300">
|
||||||
<p className="text-ui-xs text-foreground-muted uppercase tracking-wider mb-2">Expiring</p>
|
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
<p className="text-3xl font-display text-foreground">{expiringCount}</p>
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 bg-accent/10 border border-accent/20 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<Check className="w-5 h-5 text-accent" />
|
||||||
</div>
|
</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="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>
|
</div>
|
||||||
|
|
||||||
{/* Limit Warning */}
|
{/* Limit Warning */}
|
||||||
@ -525,7 +600,9 @@ export default function DashboardPage() {
|
|||||||
<tr className="border-b border-border">
|
<tr className="border-b border-border">
|
||||||
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Domain</th>
|
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Domain</th>
|
||||||
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden sm:table-cell">Status</th>
|
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden sm:table-cell">Status</th>
|
||||||
|
{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 lg:table-cell">Expiration</th>
|
||||||
|
)}
|
||||||
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden md:table-cell">Last Check</th>
|
<th className="text-left text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4 hidden md:table-cell">Last Check</th>
|
||||||
<th className="text-right text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Actions</th>
|
<th className="text-right text-ui-xs font-medium text-foreground-muted uppercase tracking-wider px-5 py-4">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -562,6 +639,7 @@ export default function DashboardPage() {
|
|||||||
{domain.is_available ? 'Available' : isMonitoring ? 'Monitoring' : 'Registered'}
|
{domain.is_available ? 'Available' : isMonitoring ? 'Monitoring' : 'Registered'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
{hasExpirationTracking && (
|
||||||
<td className="px-5 py-4 hidden lg:table-cell">
|
<td className="px-5 py-4 hidden lg:table-cell">
|
||||||
{exp ? (
|
{exp ? (
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
@ -572,6 +650,7 @@ export default function DashboardPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : <span className="text-foreground-subtle/50">—</span>}
|
) : <span className="text-foreground-subtle/50">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
)}
|
||||||
<td className="px-5 py-4 hidden md:table-cell">
|
<td className="px-5 py-4 hidden md:table-cell">
|
||||||
<span className="text-body-sm text-foreground-subtle flex items-center gap-1.5">
|
<span className="text-body-sm text-foreground-subtle flex items-center gap-1.5">
|
||||||
<Clock className="w-3.5 h-3.5" />{formatDate(domain.last_checked)}
|
<Clock className="w-3.5 h-3.5" />{formatDate(domain.last_checked)}
|
||||||
@ -579,14 +658,20 @@ export default function DashboardPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4">
|
<td className="px-5 py-4">
|
||||||
<div className="flex items-center justify-end gap-0.5 opacity-50 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center justify-end gap-0.5 opacity-50 group-hover:opacity-100 transition-opacity">
|
||||||
{isProOrHigher && (
|
{hasHistory && (
|
||||||
<button onClick={() => loadDomainHistory(domain.id)} className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/10 rounded-lg transition-all" title="History">
|
<button onClick={() => loadDomainHistory(domain.id)} className="p-2 text-foreground-subtle hover:text-foreground hover:bg-foreground/10 rounded-lg transition-all" title="History">
|
||||||
<History className="w-4 h-4" />
|
<History className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{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">
|
<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" />
|
<Sparkles className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link href="/pricing" className="p-2 text-foreground-subtle/50 hover:text-accent hover:bg-accent/10 rounded-lg transition-all" title="Upgrade for Valuation">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
onClick={() => handleToggleNotify(domain.id, domain.notify_on_available)}
|
||||||
disabled={togglingNotifyId === domain.id}
|
disabled={togglingNotifyId === domain.id}
|
||||||
@ -994,6 +1079,27 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
|
{/* Toast Notification */}
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={hideToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<DashboardContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, Suspense } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { Loader2, ArrowRight, Eye, EyeOff } from 'lucide-react'
|
import { api } from '@/lib/api'
|
||||||
|
import { Loader2, ArrowRight, Eye, EyeOff, CheckCircle } from 'lucide-react'
|
||||||
|
|
||||||
// Logo Component - Puma Image
|
// Logo Component
|
||||||
function Logo() {
|
function Logo() {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
@ -20,8 +21,29 @@ function Logo() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
// OAuth Icons
|
||||||
|
function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const { login } = useStore()
|
const { login } = useStore()
|
||||||
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@ -29,6 +51,26 @@ export default function LoginPage() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false })
|
||||||
|
const [verified, setVerified] = useState(false)
|
||||||
|
|
||||||
|
// Get redirect URL from query params
|
||||||
|
const redirectTo = searchParams.get('redirect') || '/dashboard'
|
||||||
|
|
||||||
|
// Check for verified status
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('verified') === 'true') {
|
||||||
|
setVerified(true)
|
||||||
|
}
|
||||||
|
if (searchParams.get('error')) {
|
||||||
|
setError(searchParams.get('error') === 'oauth_failed' ? 'OAuth authentication failed. Please try again.' : 'Authentication failed')
|
||||||
|
}
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
// Load OAuth providers
|
||||||
|
useEffect(() => {
|
||||||
|
api.getOAuthProviders().then(setOauthProviders).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -37,21 +79,36 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password)
|
await login(email, password)
|
||||||
router.push('/dashboard')
|
// Redirect to intended destination or dashboard
|
||||||
} catch (err) {
|
router.push(redirectTo)
|
||||||
setError(err instanceof Error ? err.message : 'Authentication failed')
|
} catch (err: unknown) {
|
||||||
|
console.error('Login error:', err)
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message || 'Authentication failed')
|
||||||
|
} else if (typeof err === 'object' && err !== null) {
|
||||||
|
if ('detail' in err) {
|
||||||
|
setError(String((err as { detail: unknown }).detail))
|
||||||
|
} else if ('message' in err) {
|
||||||
|
setError(String((err as { message: unknown }).message))
|
||||||
|
} else {
|
||||||
|
setError('Authentication failed. Please try again.')
|
||||||
|
}
|
||||||
|
} else if (typeof err === 'string') {
|
||||||
|
setError(err)
|
||||||
|
} else {
|
||||||
|
setError('Authentication failed. Please try again.')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Generate register link with redirect preserved
|
||||||
<div className="min-h-screen flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12 relative">
|
const registerLink = redirectTo !== '/dashboard'
|
||||||
{/* Ambient glow */}
|
? `/register?redirect=${encodeURIComponent(redirectTo)}`
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
: '/register'
|
||||||
<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>
|
|
||||||
|
|
||||||
|
return (
|
||||||
<div className="relative w-full max-w-sm animate-fade-in">
|
<div className="relative w-full max-w-sm animate-fade-in">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/" className="flex justify-center mb-12 sm:mb-16 hover:opacity-80 transition-opacity duration-300">
|
<Link href="/" className="flex justify-center mb-12 sm:mb-16 hover:opacity-80 transition-opacity duration-300">
|
||||||
@ -60,12 +117,22 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8 sm:mb-10">
|
<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>
|
<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">
|
<p className="text-body-sm sm:text-body text-foreground-muted">
|
||||||
Sign in to your account
|
Sign in to your account
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
@ -81,6 +148,7 @@ export default function LoginPage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Email address"
|
placeholder="Email address"
|
||||||
required
|
required
|
||||||
|
autoComplete="email"
|
||||||
className="input-elegant text-body-sm sm:text-body"
|
className="input-elegant text-body-sm sm:text-body"
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -91,6 +159,7 @@ export default function LoginPage() {
|
|||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
|
autoComplete="current-password"
|
||||||
className="input-elegant text-body-sm sm:text-body pr-12"
|
className="input-elegant text-body-sm sm:text-body pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -135,14 +204,70 @@ export default function LoginPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* OAuth Buttons */}
|
||||||
|
{(oauthProviders.google_enabled || oauthProviders.github_enabled) && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs">
|
||||||
|
<span className="px-4 bg-background text-foreground-muted">or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{oauthProviders.google_enabled && (
|
||||||
|
<a
|
||||||
|
href={api.getGoogleLoginUrl(redirectTo)}
|
||||||
|
className="w-full py-3 sm:py-3.5 bg-[#24292e] text-white text-sm font-medium rounded-xl
|
||||||
|
hover:bg-[#2f363d] border border-[#24292e]
|
||||||
|
transition-all duration-300 flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<GoogleIcon className="w-5 h-5" />
|
||||||
|
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 */}
|
{/* Register Link */}
|
||||||
<p className="mt-8 sm:mt-10 text-center text-body-xs sm:text-body-sm text-foreground-muted">
|
<p className="mt-8 sm:mt-10 text-center text-body-xs sm:text-body-sm text-foreground-muted">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link href="/register" className="text-foreground hover:text-accent transition-colors duration-300">
|
<Link href={registerLink} className="text-foreground hover:text-accent transition-colors duration-300">
|
||||||
Create one
|
Create one
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
}>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</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 { DomainChecker } from '@/components/DomainChecker'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Eye, Bell, Clock, Shield, ArrowRight, Check, TrendingUp, TrendingDown, Minus, Lock, ChevronRight } from 'lucide-react'
|
import {
|
||||||
|
Eye,
|
||||||
|
Bell,
|
||||||
|
Clock,
|
||||||
|
Shield,
|
||||||
|
ArrowRight,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Minus,
|
||||||
|
ChevronRight,
|
||||||
|
Zap,
|
||||||
|
BarChart3,
|
||||||
|
Globe,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
icon: Eye,
|
|
||||||
title: 'Always Watching',
|
|
||||||
description: 'Daily scans. 886+ TLDs. You sleep, we hunt.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Bell,
|
|
||||||
title: 'Instant Alerts',
|
|
||||||
description: 'Domain drops? You know first. Always.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Clock,
|
|
||||||
title: 'Expiry Intel',
|
|
||||||
description: 'See when domains expire. Plan your move.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
title: 'Your Strategy, Private',
|
|
||||||
description: 'No one sees your watchlist. Ever.',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const tiers = [
|
|
||||||
{
|
|
||||||
name: 'Scout',
|
|
||||||
price: '0',
|
|
||||||
period: '',
|
|
||||||
description: 'Test the waters. Zero risk.',
|
|
||||||
features: ['5 domains', 'Daily checks', 'Email alerts', 'Basic search'],
|
|
||||||
cta: 'Hunt Free',
|
|
||||||
highlighted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Trader',
|
|
||||||
price: '19',
|
|
||||||
period: '/mo',
|
|
||||||
description: 'Hunt with precision.',
|
|
||||||
features: ['50 domains', 'Hourly checks', 'SMS alerts', 'Domain valuation', 'Portfolio tracking'],
|
|
||||||
cta: 'Start Trading',
|
|
||||||
highlighted: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tycoon',
|
|
||||||
price: '49',
|
|
||||||
period: '/mo',
|
|
||||||
description: 'Dominate the market.',
|
|
||||||
features: ['500 domains', 'Real-time checks', 'API access', 'SEO metrics', 'Bulk tools'],
|
|
||||||
cta: 'Go Tycoon',
|
|
||||||
highlighted: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
interface TldData {
|
|
||||||
tld: string
|
|
||||||
type: string
|
|
||||||
description: string
|
|
||||||
avg_registration_price: number
|
|
||||||
min_registration_price: number
|
|
||||||
max_registration_price: number
|
|
||||||
trend: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrendingTld {
|
interface TrendingTld {
|
||||||
tld: string
|
tld: string
|
||||||
reason: string
|
reason: string
|
||||||
current_price: number
|
current_price: number
|
||||||
price_change: number // API returns price_change, not price_change_percent
|
price_change: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shimmer component for locked content
|
// Shimmer for loading states
|
||||||
function ShimmerBlock({ className }: { className?: string }) {
|
function Shimmer({ className }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"relative overflow-hidden rounded bg-background-tertiary",
|
"relative overflow-hidden rounded bg-foreground/5",
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-foreground/5 to-transparent" />
|
<div className="absolute inset-0 -translate-x-full animate-[shimmer_2s_infinite] bg-gradient-to-r from-transparent via-foreground/5 to-transparent" />
|
||||||
@ -93,9 +44,34 @@ function ShimmerBlock({ className }: { className?: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Animated counter
|
||||||
|
function AnimatedNumber({ value, suffix = '' }: { value: number, suffix?: string }) {
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const duration = 2000
|
||||||
|
const steps = 60
|
||||||
|
const increment = value / steps
|
||||||
|
let current = 0
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
current += increment
|
||||||
|
if (current >= value) {
|
||||||
|
setCount(value)
|
||||||
|
clearInterval(timer)
|
||||||
|
} else {
|
||||||
|
setCount(Math.floor(current))
|
||||||
|
}
|
||||||
|
}, duration / steps)
|
||||||
|
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
return <>{count.toLocaleString()}{suffix}</>
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||||
const [tldData, setTldData] = useState<TldData[]>([])
|
|
||||||
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
const [trendingTlds, setTrendingTlds] = useState<TrendingTld[]>([])
|
||||||
const [loadingTlds, setLoadingTlds] = useState(true)
|
const [loadingTlds, setLoadingTlds] = useState(true)
|
||||||
|
|
||||||
@ -106,11 +82,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const fetchTldData = async () => {
|
const fetchTldData = async () => {
|
||||||
try {
|
try {
|
||||||
const [overview, trending] = await Promise.all([
|
const trending = await api.getTrendingTlds()
|
||||||
api.getTldOverview(8),
|
|
||||||
api.getTrendingTlds()
|
|
||||||
])
|
|
||||||
setTldData(overview.tlds)
|
|
||||||
setTrendingTlds(trending.trending.slice(0, 4))
|
setTrendingTlds(trending.trending.slice(0, 4))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch TLD data:', error)
|
console.error('Failed to fetch TLD data:', error)
|
||||||
@ -121,8 +93,8 @@ export default function HomePage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -133,292 +105,338 @@ export default function HomePage() {
|
|||||||
return <Minus className="w-3.5 h-3.5" />
|
return <Minus className="w-3.5 h-3.5" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrendDirection = (priceChange: number) => {
|
|
||||||
if (priceChange > 0) return 'up'
|
|
||||||
if (priceChange < 0) return 'down'
|
|
||||||
return 'stable'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative">
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
{/* Ambient background glow */}
|
{/* Background Effects */}
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-accent/[0.03] rounded-full blur-3xl" />
|
{/* Primary glow */}
|
||||||
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
{/* Secondary glow */}
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
{/* Grid pattern */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '64px 64px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative pt-28 sm:pt-32 md:pt-36 lg:pt-40 pb-16 sm:pb-20 px-4 sm:px-6">
|
<section className="relative pt-32 sm:pt-40 md:pt-48 lg:pt-56 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||||
<div className="max-w-7xl mx-auto text-center">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Puma Logo - Compact & Elegant */}
|
<div className="text-center max-w-5xl mx-auto">
|
||||||
<div className="flex justify-center mb-6 sm:mb-8 animate-fade-in">
|
{/* Puma Logo */}
|
||||||
|
<div className="flex justify-center mb-8 sm:mb-10 animate-fade-in">
|
||||||
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src="/pounce-puma.png"
|
src="/pounce-puma.png"
|
||||||
alt="pounce"
|
alt="pounce"
|
||||||
width={300}
|
width={400}
|
||||||
height={210}
|
height={280}
|
||||||
className="w-32 h-auto sm:w-40 md:w-48 object-contain drop-shadow-[0_0_40px_rgba(16,185,129,0.25)]"
|
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
|
priority
|
||||||
/>
|
/>
|
||||||
|
{/* Glow ring */}
|
||||||
|
<div className="absolute inset-0 -z-10 bg-accent/20 blur-3xl rounded-full scale-150" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Headline - RESPONSIVE */}
|
{/* Main Headline - MASSIVE */}
|
||||||
<h1 className="font-display text-[2.25rem] leading-[1.1] sm:text-[3rem] md:text-[3.75rem] lg:text-[4.5rem] xl:text-[5.25rem] tracking-[-0.035em] mb-6 sm:mb-8 md:mb-10 animate-slide-up">
|
<h1 className="animate-slide-up">
|
||||||
<span className="block text-foreground">Others wait.</span>
|
<span className="block font-display text-[3rem] sm:text-[4rem] md:text-[5.5rem] lg:text-[7rem] xl:text-[8rem] leading-[0.9] tracking-[-0.04em] text-foreground">
|
||||||
<span className="block text-foreground-muted">You pounce.</span>
|
Others wait.
|
||||||
|
</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>
|
</h1>
|
||||||
|
|
||||||
{/* Subheadline - RESPONSIVE */}
|
{/* Subheadline */}
|
||||||
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted max-w-xl sm:max-w-2xl mx-auto mb-10 sm:mb-12 md:mb-16 animate-slide-up delay-100 px-4 sm:px-0">
|
<p className="mt-8 sm:mt-10 md:mt-12 text-lg sm:text-xl md:text-2xl text-foreground-muted max-w-2xl mx-auto animate-slide-up delay-100 leading-relaxed">
|
||||||
Domain intelligence for the decisive. Track any domain.
|
Domain intelligence for the decisive. Track any domain.
|
||||||
Know the moment it drops. Move before anyone else.
|
Know the moment it drops. Move before anyone else.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Domain Checker */}
|
{/* Domain Checker */}
|
||||||
<div className="animate-slide-up delay-150">
|
<div className="mt-10 sm:mt-14 md:mt-16 animate-slide-up delay-200">
|
||||||
<DomainChecker />
|
<DomainChecker />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trust Indicators */}
|
||||||
|
<div className="mt-12 sm:mt-16 flex flex-wrap items-center justify-center gap-8 sm:gap-12 text-foreground-subtle animate-fade-in delay-300">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm font-medium"><AnimatedNumber value={886} />+ TLDs tracked</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm font-medium">Real-time pricing</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm font-medium">Instant alerts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* TLD Price Intelligence Section */}
|
{/* Trending TLDs Section */}
|
||||||
<section className="relative py-16 sm:py-20 md:py-24 px-4 sm:px-6 border-t border-border-subtle">
|
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
<div className="text-center mb-10 sm:mb-12">
|
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-6 mb-10 sm:mb-14">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-accent-muted border border-accent/20 rounded-full mb-5">
|
<div>
|
||||||
|
<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" />
|
<TrendingUp className="w-4 h-4 text-accent" />
|
||||||
<span className="text-ui-sm text-accent">Market Intel</span>
|
<span className="text-sm font-medium text-accent">Market Intel</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-display text-[1.75rem] sm:text-[2.5rem] md:text-[3.25rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4 sm:mb-5 md:mb-6">
|
<h2 className="font-display text-3xl sm:text-4xl md:text-5xl tracking-[-0.03em] text-foreground">
|
||||||
886 TLDs. Tracked Daily.
|
Trending Now
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-body-sm sm:text-body text-foreground-muted max-w-lg mx-auto">
|
</div>
|
||||||
See price movements. Spot opportunities. Act fast.
|
<Link
|
||||||
</p>
|
href="/tld-pricing"
|
||||||
</div>
|
className="group inline-flex items-center gap-2 text-sm font-medium text-accent hover:text-accent-hover transition-colors"
|
||||||
|
>
|
||||||
{/* Trending TLDs - Card Grid */}
|
Explore all TLDs
|
||||||
<div className="mb-8">
|
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||||
<div className="flex items-center gap-2 mb-4">
|
</Link>
|
||||||
<TrendingUp className="w-4 h-4 text-accent" />
|
|
||||||
<span className="text-ui font-medium text-foreground">Trending Now</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* TLD Cards */}
|
||||||
{loadingTlds ? (
|
{loadingTlds ? (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<div key={i} className="p-5 bg-background-secondary border border-border rounded-xl">
|
<div key={i} className="p-6 bg-background-secondary/50 border border-border rounded-2xl">
|
||||||
<ShimmerBlock className="h-6 w-16 mb-3" />
|
<Shimmer className="h-8 w-20 mb-4" />
|
||||||
<ShimmerBlock className="h-4 w-full mb-2" />
|
<Shimmer className="h-4 w-full mb-2" />
|
||||||
<ShimmerBlock className="h-4 w-20" />
|
<Shimmer className="h-4 w-24" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
{trendingTlds.map((item) => (
|
{trendingTlds.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.tld}
|
key={item.tld}
|
||||||
href={isAuthenticated ? `/tld-pricing/${item.tld}` : '/register'}
|
href={isAuthenticated ? `/tld-pricing/${item.tld}` : `/login?redirect=/tld-pricing/${item.tld}`}
|
||||||
className="group p-5 bg-background-secondary border border-border rounded-xl
|
className="group relative p-6 bg-background-secondary/50 border border-border rounded-2xl
|
||||||
hover:border-border-hover transition-all duration-300"
|
hover:border-accent/30 hover:bg-background-secondary transition-all duration-300"
|
||||||
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
{/* Hover glow */}
|
||||||
<span className="font-mono text-body-lg sm:text-heading-sm text-foreground">.{item.tld}</span>
|
<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(
|
<span className={clsx(
|
||||||
"flex items-center gap-1 text-ui-sm font-medium px-2 py-0.5 rounded-full",
|
"flex items-center gap-1 text-xs font-semibold px-2.5 py-1 rounded-full",
|
||||||
(item.price_change ?? 0) > 0
|
(item.price_change ?? 0) > 0
|
||||||
? "text-[#f97316] bg-[#f9731615]"
|
? "text-orange-400 bg-orange-400/10"
|
||||||
: (item.price_change ?? 0) < 0
|
: (item.price_change ?? 0) < 0
|
||||||
? "text-accent bg-accent-muted"
|
? "text-accent bg-accent/10"
|
||||||
: "text-foreground-muted bg-background-tertiary"
|
: "text-foreground-muted bg-foreground/5"
|
||||||
)}>
|
)}>
|
||||||
{getTrendIcon(item.price_change ?? 0)}
|
{getTrendIcon(item.price_change ?? 0)}
|
||||||
{(item.price_change ?? 0) > 0 ? '+' : ''}{(item.price_change ?? 0).toFixed(1)}%
|
{(item.price_change ?? 0) > 0 ? '+' : ''}{(item.price_change ?? 0).toFixed(1)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-body-xs text-foreground-subtle mb-3 line-clamp-2">{item.reason}</p>
|
<p className="text-sm text-foreground-subtle mb-4 line-clamp-2 min-h-[40px]">{item.reason}</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<span className="text-body-sm text-foreground">${(item.current_price ?? 0).toFixed(2)}/yr</span>
|
<span className="text-lg font-semibold text-foreground">${(item.current_price ?? 0).toFixed(2)}<span className="text-sm font-normal text-foreground-muted">/yr</span></span>
|
||||||
) : (
|
) : (
|
||||||
<ShimmerBlock className="h-5 w-20" />
|
<Shimmer className="h-6 w-20" />
|
||||||
)}
|
)}
|
||||||
<ChevronRight className="w-4 h-4 text-foreground-subtle group-hover:text-accent transition-colors" />
|
<ChevronRight className="w-5 h-5 text-foreground-subtle group-hover:text-accent group-hover:translate-x-1 transition-all" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View All Link */}
|
|
||||||
<div className="mt-6 text-center">
|
|
||||||
<Link
|
|
||||||
href="/tld-pricing"
|
|
||||||
className="inline-flex items-center gap-2 text-body-sm font-medium text-accent hover:text-accent-hover transition-colors"
|
|
||||||
>
|
|
||||||
Explore All TLDs
|
|
||||||
<ArrowRight className="w-4 h-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Features Section */}
|
{/* Features Section */}
|
||||||
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
|
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="text-center mb-12 sm:mb-16 md:mb-20">
|
{/* Section Header */}
|
||||||
<p className="label sm:label-md text-accent mb-3 sm:mb-4 md:mb-5">How It Works</p>
|
<div className="text-center max-w-3xl mx-auto mb-16 sm:mb-20">
|
||||||
<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">
|
<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.
|
Built for hunters.
|
||||||
</h2>
|
</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">
|
<p className="mt-5 text-lg text-foreground-muted">
|
||||||
The tools that give you the edge. Simple. Powerful. Decisive.
|
The tools that give you the edge. Simple. Powerful. Decisive.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-5 md:gap-6">
|
{/* Feature Cards */}
|
||||||
{features.map((feature, i) => (
|
<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
|
<div
|
||||||
key={feature.title}
|
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"
|
className="group relative p-8 rounded-2xl border border-transparent hover:border-border
|
||||||
style={{ animationDelay: `${i * 100}ms` }}
|
bg-transparent hover:bg-background-secondary/50 transition-all duration-500"
|
||||||
>
|
>
|
||||||
<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
|
<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">
|
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} />
|
<feature.icon className="w-6 h-6 text-foreground-muted group-hover:text-accent transition-colors duration-500" strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-body sm:text-body-md font-medium text-foreground mb-2">{feature.title}</h3>
|
<h3 className="text-lg font-semibold text-foreground mb-3">{feature.title}</h3>
|
||||||
<p className="text-body-xs sm:text-body-sm text-foreground-subtle leading-relaxed">{feature.description}</p>
|
<p className="text-sm text-foreground-subtle leading-relaxed">{feature.description}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Section */}
|
{/* Social Proof / Stats Section */}
|
||||||
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
|
<section className="relative py-20 sm:py-28 px-4 sm:px-6">
|
||||||
{/* Section glow */}
|
<div className="max-w-5xl mx-auto">
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
<div className="relative p-10 sm:p-14 md:p-20 bg-gradient-to-br from-background-secondary/80 to-background-secondary/40
|
||||||
<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" />
|
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>
|
||||||
|
|
||||||
<div className="relative max-w-5xl mx-auto">
|
<div className="relative grid sm:grid-cols-3 gap-10 sm:gap-6 text-center">
|
||||||
<div className="text-center mb-12 sm:mb-16 md:mb-20">
|
<div>
|
||||||
<p className="label sm:label-md text-accent mb-3 sm:mb-4 md:mb-5">Pricing</p>
|
<p className="font-display text-5xl sm:text-6xl md:text-7xl text-foreground mb-2">
|
||||||
<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">
|
<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.
|
Pick your weapon.
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-body-sm sm:text-body md:text-body-lg text-foreground-muted">
|
<p className="mt-5 text-lg text-foreground-muted max-w-xl mx-auto">
|
||||||
Start free. Scale when you're ready.
|
Start free with 5 domains. Scale to 500+ when you need more firepower.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-4 sm:gap-5">
|
{/* Quick Plans */}
|
||||||
{tiers.map((tier, i) => (
|
<div className="mt-12 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<div
|
<div className="flex items-center gap-4 px-6 py-4 bg-background-secondary/50 border border-border rounded-2xl">
|
||||||
key={tier.name}
|
<div className="w-12 h-12 bg-foreground/5 rounded-xl flex items-center justify-center">
|
||||||
className={`relative p-5 sm:p-6 md:p-7 rounded-2xl border transition-all duration-500 ${
|
<Zap className="w-5 h-5 text-foreground-muted" />
|
||||||
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>
|
||||||
)}
|
<div className="text-left">
|
||||||
|
<p className="font-semibold text-foreground">Scout</p>
|
||||||
<div className="mb-5 sm:mb-6">
|
<p className="text-sm text-foreground-muted">Free forever</p>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul className="space-y-2.5 sm:space-y-3 mb-6 sm:mb-8">
|
<ArrowRight className="w-5 h-5 text-foreground-subtle hidden sm:block" />
|
||||||
{tier.features.map((feature) => (
|
<ChevronRight className="w-5 h-5 text-foreground-subtle rotate-90 sm:hidden" />
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 px-6 py-4 bg-accent/5 border border-accent/20 rounded-2xl">
|
||||||
|
<div className="w-12 h-12 bg-accent/10 rounded-xl flex items-center justify-center">
|
||||||
|
<TrendingUp className="w-5 h-5 text-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-semibold text-foreground">Trader</p>
|
||||||
|
<p className="text-sm text-accent">$19/month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
href={isAuthenticated ? '/dashboard' : '/register'}
|
href="/pricing"
|
||||||
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 ${
|
className="inline-flex items-center gap-2 px-8 py-4 bg-foreground text-background rounded-xl
|
||||||
tier.highlighted
|
font-semibold hover:bg-foreground/90 transition-all duration-300"
|
||||||
? 'bg-accent text-background hover:bg-accent-hover'
|
|
||||||
: 'bg-background-tertiary text-foreground border border-border hover:border-border-hover'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tier.cta}
|
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>
|
</Link>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* Final CTA */}
|
||||||
<section className="relative py-20 sm:py-24 md:py-32 px-4 sm:px-6">
|
<section className="relative py-24 sm:py-32 px-4 sm:px-6">
|
||||||
<div className="max-w-2xl mx-auto text-center">
|
<div className="max-w-4xl 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">
|
<h2 className="font-display text-4xl sm:text-5xl md:text-6xl lg:text-7xl tracking-[-0.03em] text-foreground mb-6">
|
||||||
Start monitoring today
|
Ready to hunt?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-body-md sm:text-body-lg md:text-body-xl text-foreground-muted mb-8 sm:mb-10">
|
<p className="text-xl text-foreground-muted mb-10 max-w-lg mx-auto">
|
||||||
Create a free account and track up to 3 domains.
|
Track your first domain in under a minute. No credit card required.
|
||||||
No credit card required.
|
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href={isAuthenticated ? "/dashboard" : "/register"}
|
||||||
className="btn-primary inline-flex items-center gap-2 sm:gap-2.5 px-6 sm:px-8 py-3 sm:py-4 text-body-sm sm:text-body"
|
className="group inline-flex items-center gap-3 px-10 py-5 bg-accent text-background rounded-2xl
|
||||||
|
text-lg font-semibold hover:bg-accent-hover transition-all duration-300
|
||||||
|
shadow-[0_0_40px_rgba(16,185,129,0.2)] hover:shadow-[0_0_60px_rgba(16,185,129,0.3)]"
|
||||||
>
|
>
|
||||||
Get Started Free
|
{isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{!isAuthenticated && (
|
||||||
|
<p className="mt-6 text-sm text-foreground-subtle">
|
||||||
|
<Check className="w-4 h-4 inline mr-1 text-accent" />
|
||||||
|
Free forever • No credit card • 5 domains included
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { Header } from '@/components/Header'
|
|||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Check, ArrowRight, Zap, TrendingUp, Crown, Briefcase, Shield, Bell, Clock, BarChart3, Code, Globe, Loader2 } from 'lucide-react'
|
import { Check, ArrowRight, Zap, TrendingUp, Crown, Loader2, Clock, X } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@ -19,11 +19,10 @@ const tiers = [
|
|||||||
period: '',
|
period: '',
|
||||||
description: 'Test the waters. Zero risk.',
|
description: 'Test the waters. Zero risk.',
|
||||||
features: [
|
features: [
|
||||||
{ text: '5 domains to track', highlight: false },
|
{ text: '5 domains to track', highlight: false, available: true },
|
||||||
{ text: 'Daily availability scans', highlight: false },
|
{ text: 'Daily availability scans', highlight: false, available: true },
|
||||||
{ text: 'Email alerts', highlight: false },
|
{ text: 'Email alerts', highlight: false, available: true },
|
||||||
{ text: 'Basic search', highlight: false },
|
{ text: 'TLD price overview', highlight: false, available: true },
|
||||||
{ text: 'TLD price overview', highlight: false },
|
|
||||||
],
|
],
|
||||||
cta: 'Hunt Free',
|
cta: 'Hunt Free',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
@ -38,13 +37,14 @@ const tiers = [
|
|||||||
period: '/mo',
|
period: '/mo',
|
||||||
description: 'Hunt with precision. Daily intel.',
|
description: 'Hunt with precision. Daily intel.',
|
||||||
features: [
|
features: [
|
||||||
{ text: '50 domains to track', highlight: true },
|
{ text: '50 domains to track', highlight: true, available: true },
|
||||||
{ text: 'Hourly scans', highlight: true },
|
{ text: 'Hourly scans', highlight: true, available: true },
|
||||||
{ text: 'SMS & Telegram alerts', highlight: true },
|
{ text: 'Email alerts', highlight: false, available: true },
|
||||||
{ text: 'Full market data', highlight: false },
|
{ text: 'Full TLD market data', highlight: false, available: true },
|
||||||
{ text: 'Domain valuation', highlight: true },
|
{ text: 'Domain valuation', highlight: true, available: true },
|
||||||
{ text: 'Portfolio (25 domains)', highlight: true },
|
{ text: 'Portfolio (25 domains)', highlight: true, available: true },
|
||||||
{ text: '90-day price history', highlight: false },
|
{ text: '90-day price history', highlight: false, available: true },
|
||||||
|
{ text: 'Expiry tracking', highlight: true, available: true },
|
||||||
],
|
],
|
||||||
cta: 'Start Trading',
|
cta: 'Start Trading',
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
@ -59,14 +59,12 @@ const tiers = [
|
|||||||
period: '/mo',
|
period: '/mo',
|
||||||
description: 'Dominate the market. No limits.',
|
description: 'Dominate the market. No limits.',
|
||||||
features: [
|
features: [
|
||||||
{ text: '500 domains to track', highlight: true },
|
{ text: '500 domains to track', highlight: true, available: true },
|
||||||
{ text: 'Real-time scans (10 min)', highlight: true },
|
{ text: 'Real-time scans (10 min)', highlight: true, available: true },
|
||||||
{ text: 'Priority alerts + Webhooks', highlight: true },
|
{ text: 'Priority email alerts', highlight: false, available: true },
|
||||||
{ text: 'Full REST API', highlight: true },
|
{ text: 'Unlimited portfolio', highlight: true, available: true },
|
||||||
{ text: 'SEO metrics (DA/PA)', highlight: true },
|
{ text: 'Full price history', highlight: true, available: true },
|
||||||
{ text: 'Unlimited portfolio', highlight: true },
|
{ text: 'Advanced valuation', highlight: true, available: true },
|
||||||
{ text: 'Bulk import/export', highlight: true },
|
|
||||||
{ text: 'White-label reports', highlight: false },
|
|
||||||
],
|
],
|
||||||
cta: 'Go Tycoon',
|
cta: 'Go Tycoon',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
@ -80,10 +78,8 @@ const comparisonFeatures = [
|
|||||||
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
|
{ name: 'Check Frequency', scout: 'Daily', trader: 'Hourly', tycoon: '10 min' },
|
||||||
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
|
{ name: 'Portfolio Domains', scout: '—', trader: '25', tycoon: 'Unlimited' },
|
||||||
{ name: 'Domain Valuation', scout: '—', trader: '✓', tycoon: '✓' },
|
{ name: 'Domain Valuation', scout: '—', trader: '✓', tycoon: '✓' },
|
||||||
{ name: 'SEO Metrics', scout: '—', trader: '—', tycoon: '✓' },
|
{ name: 'Price History', scout: '—', trader: '90 days', tycoon: 'Unlimited' },
|
||||||
{ name: 'API Access', scout: '—', trader: '—', tycoon: '✓' },
|
{ name: 'Expiry Tracking', scout: '—', trader: '✓', tycoon: '✓' },
|
||||||
{ name: 'Webhooks', scout: '—', trader: '—', tycoon: '✓' },
|
|
||||||
{ name: 'SMS/Telegram', scout: '—', trader: '✓', tycoon: '✓' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const faqs = [
|
const faqs = [
|
||||||
@ -99,10 +95,6 @@ const faqs = [
|
|||||||
q: 'Can I track domains I already own?',
|
q: 'Can I track domains I already own?',
|
||||||
a: 'Absolutely. Trader and Tycoon plans include portfolio tracking. See value changes, renewal dates, and ROI at a glance.',
|
a: 'Absolutely. Trader and Tycoon plans include portfolio tracking. See value changes, renewal dates, and ROI at a glance.',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
q: 'How accurate is the SEO data?',
|
|
||||||
a: 'We integrate with industry-standard APIs to provide Domain Authority, Page Authority, and backlink data. Data is refreshed weekly for accuracy.',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
q: 'Can I upgrade or downgrade anytime?',
|
q: 'Can I upgrade or downgrade anytime?',
|
||||||
a: 'Absolutely. You can change your plan at any time. Upgrades take effect immediately, and downgrades apply at the next billing cycle.',
|
a: 'Absolutely. You can change your plan at any time. Upgrades take effect immediately, and downgrades apply at the next billing cycle.',
|
||||||
@ -116,200 +108,134 @@ const faqs = [
|
|||||||
export default function PricingPage() {
|
export default function PricingPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
const { checkAuth, isLoading, isAuthenticated } = useStore()
|
||||||
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly')
|
|
||||||
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [expandedFaq, setExpandedFaq] = useState<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth()
|
checkAuth()
|
||||||
}, [checkAuth])
|
}, [checkAuth])
|
||||||
|
|
||||||
const getPrice = (basePrice: string) => {
|
const handleSelectPlan = async (planId: string, isPaid: boolean) => {
|
||||||
if (basePrice === '0') return '0'
|
|
||||||
const price = parseFloat(basePrice)
|
|
||||||
if (billingCycle === 'yearly') {
|
|
||||||
return (price * 10).toFixed(0) // 2 months free
|
|
||||||
}
|
|
||||||
return basePrice
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelectPlan = async (tier: typeof tiers[0]) => {
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
// Free plan - go to register or dashboard
|
|
||||||
if (!tier.isPaid) {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
router.push('/dashboard')
|
|
||||||
} else {
|
|
||||||
router.push('/register')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paid plan - need authentication first
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Save intended plan and redirect to register
|
router.push(`/register?redirect=/pricing`)
|
||||||
sessionStorage.setItem('intended_plan', tier.id)
|
|
||||||
router.push('/register')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticated user - create Stripe checkout
|
if (!isPaid) {
|
||||||
setLoadingPlan(tier.id)
|
router.push('/dashboard')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingPlan(planId)
|
||||||
try {
|
try {
|
||||||
const { checkout_url } = await api.createCheckoutSession(
|
const response = await api.createCheckoutSession(
|
||||||
tier.id,
|
planId,
|
||||||
`${window.location.origin}/dashboard?upgraded=true&plan=${tier.id}`,
|
`${window.location.origin}/dashboard?upgraded=true`,
|
||||||
`${window.location.origin}/pricing?cancelled=true`
|
`${window.location.origin}/pricing`
|
||||||
)
|
)
|
||||||
|
window.location.href = response.checkout_url
|
||||||
// Redirect to Stripe Checkout
|
} catch (error) {
|
||||||
window.location.href = checkout_url
|
console.error('Failed to create checkout:', error)
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Checkout error:', err)
|
|
||||||
setError(err.message || 'Failed to start checkout. Please try again.')
|
|
||||||
setLoadingPlan(null)
|
setLoadingPlan(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
{/* Background Effects - matching landing page */}
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background relative flex flex-col">
|
|
||||||
{/* Ambient glow */}
|
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '64px 64px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="relative pt-28 sm:pt-32 md:pt-36 pb-16 sm:pb-20 px-4 sm:px-6 flex-1">
|
<main className="flex-1 relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Header */}
|
{/* Hero */}
|
||||||
<div className="text-center mb-10 sm:mb-12">
|
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6 animate-fade-in">
|
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Pricing</span>
|
||||||
<Briefcase className="w-4 h-4 text-accent" />
|
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||||
<span className="text-ui-sm text-foreground-muted">Pricing</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="font-display text-[2.25rem] sm:text-[3rem] md:text-[3.75rem] leading-[1.1] tracking-[-0.035em] text-foreground mb-4 animate-slide-up">
|
|
||||||
Pick your weapon.
|
Pick your weapon.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-body-lg text-foreground-muted max-w-2xl mx-auto mb-8 animate-slide-up">
|
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-xl mx-auto">
|
||||||
Casual observer or full-time hunter? We've got you covered.
|
Start free. Scale when you're ready. All plans include core features.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div className="max-w-md mx-auto mb-6 p-4 bg-danger/10 border border-danger/20 rounded-xl text-danger text-body-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Billing Toggle */}
|
|
||||||
<div className="inline-flex items-center gap-3 p-1.5 bg-background-secondary border border-border rounded-xl animate-slide-up">
|
|
||||||
<button
|
|
||||||
onClick={() => setBillingCycle('monthly')}
|
|
||||||
className={clsx(
|
|
||||||
"px-4 py-2 text-ui-sm font-medium rounded-lg transition-all",
|
|
||||||
billingCycle === 'monthly'
|
|
||||||
? "bg-foreground text-background"
|
|
||||||
: "text-foreground-muted hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Monthly
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setBillingCycle('yearly')}
|
|
||||||
className={clsx(
|
|
||||||
"px-4 py-2 text-ui-sm font-medium rounded-lg transition-all flex items-center gap-2",
|
|
||||||
billingCycle === 'yearly'
|
|
||||||
? "bg-foreground text-background"
|
|
||||||
: "text-foreground-muted hover:text-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Yearly
|
|
||||||
<span className="text-ui-xs px-1.5 py-0.5 bg-accent text-background rounded-full">
|
|
||||||
-17%
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing Cards */}
|
{/* Pricing Cards */}
|
||||||
<div className="grid md:grid-cols-3 gap-4 sm:gap-6 mb-16 sm:mb-20">
|
<div className="grid md:grid-cols-3 gap-6 mb-20 animate-slide-up">
|
||||||
{tiers.map((tier, i) => (
|
{tiers.map((tier, index) => (
|
||||||
<div
|
<div
|
||||||
key={tier.name}
|
key={tier.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative p-6 sm:p-8 rounded-2xl border transition-all duration-500 animate-slide-up",
|
"group relative p-6 sm:p-8 rounded-2xl border transition-all duration-500",
|
||||||
tier.highlighted
|
tier.highlighted
|
||||||
? 'bg-background-secondary border-accent/30 shadow-[0_0_60px_-20px_rgba(16,185,129,0.3)]'
|
? "bg-background-secondary border-accent/30 shadow-lg shadow-accent/5"
|
||||||
: 'bg-background-secondary/50 border-border hover:border-border-hover'
|
: "bg-background-secondary/50 border-border hover:border-accent/20 hover:bg-background-secondary"
|
||||||
)}
|
)}
|
||||||
style={{ animationDelay: `${100 + i * 100}ms` }}
|
style={{ animationDelay: `${index * 100}ms` }}
|
||||||
>
|
>
|
||||||
|
{/* Hover glow for non-highlighted */}
|
||||||
|
{!tier.highlighted && (
|
||||||
|
<div className="absolute inset-0 rounded-2xl bg-accent/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
)}
|
||||||
|
|
||||||
{tier.badge && (
|
{tier.badge && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||||
<span className={clsx(
|
<span className="px-3 py-1 bg-accent text-background text-ui-xs font-medium rounded-full whitespace-nowrap">
|
||||||
"px-3 py-1 text-ui-xs font-medium rounded-full",
|
|
||||||
tier.highlighted
|
|
||||||
? "bg-accent text-background"
|
|
||||||
: "bg-foreground text-background"
|
|
||||||
)}>
|
|
||||||
{tier.badge}
|
{tier.badge}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Icon & Name */}
|
<div className="relative">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
"w-10 h-10 rounded-xl flex items-center justify-center",
|
"w-12 h-12 rounded-2xl flex items-center justify-center border transition-all duration-500",
|
||||||
tier.highlighted ? "bg-accent/20" : "bg-background-tertiary"
|
tier.highlighted
|
||||||
|
? "bg-accent/10 border-accent/30"
|
||||||
|
: "bg-foreground/5 border-border group-hover:border-accent/30 group-hover:bg-accent/5"
|
||||||
)}>
|
)}>
|
||||||
<tier.icon className={clsx(
|
<tier.icon className={clsx(
|
||||||
"w-5 h-5",
|
"w-5 h-5 transition-colors duration-500",
|
||||||
tier.highlighted ? "text-accent" : "text-foreground-muted"
|
tier.highlighted ? "text-accent" : "text-foreground-muted group-hover:text-accent"
|
||||||
)} />
|
)} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-body-lg font-medium text-foreground">{tier.name}</h3>
|
<h3 className="text-xl font-semibold text-foreground">{tier.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-body-sm text-foreground-muted mb-4">{tier.description}</p>
|
||||||
<p className="text-body-sm text-foreground-muted mb-5">{tier.description}</p>
|
<div className="flex items-baseline gap-1">
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="flex items-baseline gap-1 mb-6">
|
|
||||||
{tier.price === '0' ? (
|
{tier.price === '0' ? (
|
||||||
<span className="text-[2.5rem] font-display text-foreground">Free</span>
|
<span className="text-5xl font-display text-foreground">Free</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="text-ui text-foreground-subtle">€</span>
|
<span className="text-5xl font-display text-foreground">${tier.price}</span>
|
||||||
<span className="text-[2.5rem] font-display text-foreground leading-none">
|
<span className="text-body text-foreground-muted">{tier.period}</span>
|
||||||
{getPrice(tier.price)}
|
|
||||||
</span>
|
|
||||||
<span className="text-body-sm text-foreground-subtle">
|
|
||||||
/{billingCycle === 'yearly' ? 'year' : 'mo'}
|
|
||||||
</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Features */}
|
{/* Features */}
|
||||||
<ul className="space-y-3 mb-8">
|
<ul className="space-y-3 mb-8">
|
||||||
{tier.features.map((feature) => (
|
{tier.features.map((feature) => (
|
||||||
<li key={feature.text} className="flex items-start gap-3 text-body-sm">
|
<li key={feature.text} className="flex items-start gap-3">
|
||||||
<Check className={clsx(
|
<Check className={clsx(
|
||||||
"w-4 h-4 shrink-0 mt-0.5",
|
"w-4 h-4 mt-0.5 shrink-0",
|
||||||
feature.highlight ? "text-accent" : "text-foreground-subtle"
|
feature.highlight ? "text-accent" : "text-foreground-muted"
|
||||||
)} strokeWidth={2.5} />
|
)} strokeWidth={2.5} />
|
||||||
<span className={clsx(
|
<span className={clsx(
|
||||||
|
"text-body-sm",
|
||||||
feature.highlight ? "text-foreground" : "text-foreground-muted"
|
feature.highlight ? "text-foreground" : "text-foreground-muted"
|
||||||
)}>
|
)}>
|
||||||
{feature.text}
|
{feature.text}
|
||||||
@ -318,16 +244,15 @@ export default function PricingPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* CTA */}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSelectPlan(tier)}
|
onClick={() => handleSelectPlan(tier.id, tier.isPaid)}
|
||||||
disabled={loadingPlan === tier.id}
|
disabled={loadingPlan === tier.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-ui font-medium transition-all",
|
"w-full flex items-center justify-center gap-2 py-4 rounded-xl text-ui font-medium transition-all duration-300",
|
||||||
tier.highlighted
|
tier.highlighted
|
||||||
? 'bg-accent text-background hover:bg-accent-hover disabled:bg-accent/50'
|
? "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 disabled:bg-foreground/50',
|
: "bg-foreground text-background hover:bg-foreground/90"
|
||||||
loadingPlan === tier.id && 'cursor-not-allowed'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{loadingPlan === tier.id ? (
|
{loadingPlan === tier.id ? (
|
||||||
@ -340,84 +265,79 @@ export default function PricingPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature Comparison Table */}
|
{/* Comparison Table */}
|
||||||
<div className="mb-16 sm:mb-20">
|
<div className="mb-20">
|
||||||
<h2 className="text-heading-md font-medium text-foreground text-center mb-8">
|
<h2 className="text-heading-md text-foreground text-center mb-8">Compare Plans</h2>
|
||||||
Compare Plans
|
|
||||||
</h2>
|
|
||||||
<div className="bg-background-secondary/30 border border-border rounded-2xl overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border">
|
<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-left py-4 px-4 text-body-sm font-medium text-foreground-muted">Feature</th>
|
||||||
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Scout</th>
|
<th className="text-center py-4 px-4 text-body-sm font-medium text-foreground-muted">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 py-4 px-4 text-body-sm font-medium text-accent">Trader</th>
|
||||||
<th className="text-center text-ui-sm text-foreground-subtle font-medium px-4 py-4">Tycoon</th>
|
<th className="text-center py-4 px-4 text-body-sm font-medium text-foreground-muted">Tycoon</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-border">
|
<tbody>
|
||||||
{comparisonFeatures.map((feature) => (
|
{comparisonFeatures.map((feature) => (
|
||||||
<tr key={feature.name}>
|
<tr key={feature.name} className="border-b border-border/50">
|
||||||
<td className="text-body-sm text-foreground px-6 py-4">{feature.name}</td>
|
<td className="py-4 px-4 text-body-sm text-foreground">{feature.name}</td>
|
||||||
<td className="text-body-sm text-foreground-muted text-center px-4 py-4">{feature.scout}</td>
|
<td className="py-4 px-4 text-center text-body-sm text-foreground-muted">{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="py-4 px-4 text-center text-body-sm text-foreground">{feature.trader}</td>
|
||||||
<td className="text-body-sm text-foreground-muted text-center px-4 py-4">{feature.tycoon}</td>
|
<td className="py-4 px-4 text-center text-body-sm text-foreground">{feature.tycoon}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trust Badges */}
|
{/* FAQ */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-16 sm:mb-20">
|
|
||||||
{[
|
|
||||||
{ icon: Shield, text: '256-bit SSL' },
|
|
||||||
{ icon: Bell, text: 'Instant Alerts' },
|
|
||||||
{ icon: Clock, text: '99.9% Uptime' },
|
|
||||||
{ icon: Globe, text: '500+ TLDs' },
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.text} className="flex items-center justify-center gap-2 p-4 bg-background-secondary/30 border border-border rounded-xl">
|
|
||||||
<item.icon className="w-4 h-4 text-accent" />
|
|
||||||
<span className="text-ui-sm text-foreground-muted">{item.text}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FAQ Section */}
|
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<h2 className="text-heading-md font-medium text-foreground text-center mb-8">
|
<h2 className="text-heading-md text-foreground text-center mb-8">Frequently Asked</h2>
|
||||||
Frequently Asked Questions
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{faqs.map((faq, i) => (
|
{faqs.map((faq, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="p-5 sm:p-6 bg-background-secondary/50 border border-border rounded-xl hover:border-border-hover transition-all"
|
className="border border-border rounded-xl overflow-hidden"
|
||||||
>
|
>
|
||||||
<h3 className="text-body font-medium text-foreground mb-2">{faq.q}</h3>
|
<button
|
||||||
<p className="text-body-sm text-foreground-muted leading-relaxed">{faq.a}</p>
|
onClick={() => setExpandedFaq(expandedFaq === i ? null : i)}
|
||||||
|
className="w-full flex items-center justify-between p-5 text-left hover:bg-foreground/5 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-body font-medium text-foreground pr-4">{faq.q}</span>
|
||||||
|
<span className={clsx(
|
||||||
|
"shrink-0 w-6 h-6 flex items-center justify-center rounded-lg transition-all",
|
||||||
|
expandedFaq === i ? "bg-accent/10 text-accent rotate-45" : "bg-foreground/5 text-foreground-muted"
|
||||||
|
)}>
|
||||||
|
<span className="text-lg leading-none">+</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{expandedFaq === i && (
|
||||||
|
<div className="px-5 pb-5">
|
||||||
|
<p className="text-body-sm text-foreground-muted">{faq.a}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* Bottom CTA */}
|
||||||
<div className="mt-16 sm:mt-20 text-center">
|
<div className="text-center mt-20 py-12 px-6 bg-background-secondary/50 border border-border rounded-2xl">
|
||||||
|
<h2 className="text-heading-md text-foreground mb-3">Not sure yet?</h2>
|
||||||
<p className="text-body text-foreground-muted mb-6">
|
<p className="text-body text-foreground-muted mb-6">
|
||||||
Not sure which plan is right for you?
|
Start with Scout. It's free forever. Upgrade when you need more.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/contact"
|
href={isAuthenticated ? "/dashboard" : "/register"}
|
||||||
className="inline-flex items-center gap-2 px-6 py-3 bg-background-secondary border border-border text-foreground font-medium rounded-xl hover:border-border-hover transition-all"
|
className="btn-primary inline-flex items-center gap-2 px-6 py-3"
|
||||||
>
|
>
|
||||||
Contact Sales
|
{isAuthenticated ? "Go to Dashboard" : "Get Started Free"}
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, Suspense } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { Loader2, ArrowRight, Check, Eye, EyeOff } from 'lucide-react'
|
import { api } from '@/lib/api'
|
||||||
|
import { Loader2, ArrowRight, Check, Eye, EyeOff, Mail } from 'lucide-react'
|
||||||
|
|
||||||
// Logo Component - Puma Image
|
// Logo Component
|
||||||
function Logo() {
|
function Logo() {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
@ -20,6 +21,26 @@ function Logo() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth Icons
|
||||||
|
function GoogleIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
||||||
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
||||||
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
||||||
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GitHubIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const benefits = [
|
const benefits = [
|
||||||
'Track up to 5 domains. Free.',
|
'Track up to 5 domains. Free.',
|
||||||
'Daily scans. You never miss a drop.',
|
'Daily scans. You never miss a drop.',
|
||||||
@ -27,8 +48,9 @@ const benefits = [
|
|||||||
'Expiry intel. Plan your move.',
|
'Expiry intel. Plan your move.',
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function RegisterPage() {
|
function RegisterForm() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const { register } = useStore()
|
const { register } = useStore()
|
||||||
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
@ -36,6 +58,16 @@ export default function RegisterPage() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [oauthProviders, setOauthProviders] = useState({ google_enabled: false, github_enabled: false })
|
||||||
|
const [registered, setRegistered] = useState(false)
|
||||||
|
|
||||||
|
// Get redirect URL from query params
|
||||||
|
const redirectTo = searchParams.get('redirect') || '/dashboard'
|
||||||
|
|
||||||
|
// Load OAuth providers
|
||||||
|
useEffect(() => {
|
||||||
|
api.getOAuthProviders().then(setOauthProviders).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@ -44,7 +76,8 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await register(email, password)
|
await register(email, password)
|
||||||
router.push('/dashboard')
|
// Show verification message
|
||||||
|
setRegistered(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||||
} finally {
|
} finally {
|
||||||
@ -52,6 +85,51 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate login link with redirect preserved
|
||||||
|
const loginLink = redirectTo !== '/dashboard'
|
||||||
|
? `/login?redirect=${encodeURIComponent(redirectTo)}`
|
||||||
|
: '/login'
|
||||||
|
|
||||||
|
// Show verification message after registration
|
||||||
|
if (registered) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4 sm:px-6 py-8 sm:py-12 relative">
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-1/4 left-1/3 w-[400px] h-[300px] bg-accent/[0.02] rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-md text-center animate-fade-in">
|
||||||
|
<div className="w-20 h-20 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Mail className="w-10 h-10 text-accent" />
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-3xl sm:text-4xl text-foreground mb-4">
|
||||||
|
Check your inbox
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-foreground-muted mb-6">
|
||||||
|
We've sent a verification link to <strong className="text-foreground">{email}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-foreground-subtle mb-8">
|
||||||
|
Click the link in the email to verify your account and start hunting domains.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Link
|
||||||
|
href={loginLink}
|
||||||
|
className="block w-full py-3 bg-foreground text-background text-sm font-medium rounded-xl hover:bg-foreground/90 transition-all"
|
||||||
|
>
|
||||||
|
Go to Login
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/verify-email"
|
||||||
|
className="block w-full py-3 border border-border text-foreground-muted text-sm font-medium rounded-xl hover:border-foreground/20 transition-all"
|
||||||
|
>
|
||||||
|
Resend Verification Email
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex relative">
|
<div className="min-h-screen flex relative">
|
||||||
{/* Ambient glow */}
|
{/* Ambient glow */}
|
||||||
@ -69,7 +147,9 @@ export default function RegisterPage() {
|
|||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8 sm:mb-10">
|
<div className="mb-8 sm:mb-10">
|
||||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">Join the hunt.</h1>
|
<h1 className="font-display text-[2rem] sm:text-[2.5rem] md:text-[3rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2 sm:mb-3">
|
||||||
|
Join the hunt.
|
||||||
|
</h1>
|
||||||
<p className="text-body-sm sm:text-body text-foreground-muted">
|
<p className="text-body-sm sm:text-body text-foreground-muted">
|
||||||
Start tracking domains in under a minute
|
Start tracking domains in under a minute
|
||||||
</p>
|
</p>
|
||||||
@ -90,6 +170,7 @@ export default function RegisterPage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="Email address"
|
placeholder="Email address"
|
||||||
required
|
required
|
||||||
|
autoComplete="email"
|
||||||
className="input-elegant text-body-sm sm:text-body"
|
className="input-elegant text-body-sm sm:text-body"
|
||||||
/>
|
/>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -100,6 +181,7 @@ export default function RegisterPage() {
|
|||||||
placeholder="Create password (min. 8 characters)"
|
placeholder="Create password (min. 8 characters)"
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
className="input-elegant text-body-sm sm:text-body pr-12"
|
className="input-elegant text-body-sm sm:text-body pr-12"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@ -135,10 +217,50 @@ export default function RegisterPage() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* OAuth Buttons */}
|
||||||
|
{(oauthProviders.google_enabled || oauthProviders.github_enabled) && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-border" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs">
|
||||||
|
<span className="px-4 bg-background text-foreground-muted">or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{oauthProviders.google_enabled && (
|
||||||
|
<a
|
||||||
|
href={api.getGoogleLoginUrl(redirectTo)}
|
||||||
|
className="w-full py-3 sm:py-3.5 bg-[#24292e] text-white text-sm font-medium rounded-xl
|
||||||
|
hover:bg-[#2f363d] border border-[#24292e]
|
||||||
|
transition-all duration-300 flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<GoogleIcon className="w-5 h-5" />
|
||||||
|
Sign up with Google
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{oauthProviders.github_enabled && (
|
||||||
|
<a
|
||||||
|
href={api.getGitHubLoginUrl(redirectTo)}
|
||||||
|
className="w-full py-3 sm:py-3.5 bg-[#24292e] text-white text-sm font-medium rounded-xl
|
||||||
|
hover:bg-[#2f363d] border border-[#24292e]
|
||||||
|
transition-all duration-300 flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<GitHubIcon className="w-5 h-5" />
|
||||||
|
Sign up with GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Login Link */}
|
{/* Login Link */}
|
||||||
<p className="mt-8 sm:mt-10 text-body-xs sm:text-body-sm text-foreground-muted">
|
<p className="mt-8 sm:mt-10 text-body-xs sm:text-body-sm text-foreground-muted">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<Link href="/login" className="text-foreground hover:text-accent transition-colors duration-300">
|
<Link href={loginLink} className="text-foreground hover:text-accent transition-colors duration-300">
|
||||||
Sign in
|
Sign in
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
@ -178,3 +300,15 @@ export default function RegisterPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="w-5 h-5 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<RegisterForm />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@ -182,18 +182,32 @@ export default function SettingsPage() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background flex flex-col">
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
|
{/* Background Effects - matching landing page */}
|
||||||
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '64px 64px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="flex-1 pt-28 sm:pt-32 pb-20 sm:pb-24 px-4 sm:px-6 lg:px-8">
|
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-10">
|
<div className="mb-12 sm:mb-16 animate-fade-in">
|
||||||
<h1 className="font-display text-[2rem] sm:text-[2.5rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-2">
|
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Settings</span>
|
||||||
Settings
|
<h1 className="mt-4 font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] leading-[1.1] tracking-[-0.03em] text-foreground">
|
||||||
|
Your account.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-body text-foreground-muted">
|
<p className="mt-3 text-lg text-foreground-muted">
|
||||||
Your account. Your rules.
|
Your rules. Configure everything in one place.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -218,9 +232,9 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row gap-8">
|
<div className="flex flex-col lg:flex-row gap-8 animate-slide-up">
|
||||||
{/* Sidebar - Horizontal scroll on mobile, vertical on desktop */}
|
{/* Sidebar - Horizontal scroll on mobile, vertical on desktop */}
|
||||||
<div className="lg:w-64 shrink-0">
|
<div className="lg:w-72 shrink-0">
|
||||||
{/* Mobile: Horizontal scroll tabs */}
|
{/* Mobile: Horizontal scroll tabs */}
|
||||||
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
|
<nav className="lg:hidden flex gap-2 overflow-x-auto pb-2 -mx-4 px-4 scrollbar-hide">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
@ -228,10 +242,10 @@ export default function SettingsPage() {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 px-5 py-2.5 text-ui font-medium rounded-xl whitespace-nowrap transition-all",
|
"flex items-center gap-2.5 px-5 py-3 text-sm font-medium rounded-xl whitespace-nowrap transition-all duration-300",
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? "bg-foreground text-background shadow-lg"
|
? "bg-accent text-background shadow-lg shadow-accent/20"
|
||||||
: "bg-background-secondary text-foreground-muted hover:text-foreground border border-border"
|
: "bg-background-secondary/50 text-foreground-muted hover:text-foreground border border-border hover:border-accent/30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<tab.icon className="w-4 h-4" />
|
<tab.icon className="w-4 h-4" />
|
||||||
@ -241,15 +255,15 @@ export default function SettingsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Desktop: Vertical tabs */}
|
{/* Desktop: Vertical tabs */}
|
||||||
<nav className="hidden lg:block p-1.5 bg-background-secondary/70 border border-border rounded-2xl">
|
<nav className="hidden lg:block p-2 bg-background-secondary/50 border border-border rounded-2xl">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full flex items-center gap-3 px-4 py-3 text-ui font-medium rounded-xl transition-all",
|
"w-full flex items-center gap-3 px-5 py-3.5 text-sm font-medium rounded-xl transition-all duration-300",
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? "bg-foreground text-background shadow-lg"
|
? "bg-accent text-background shadow-lg shadow-accent/20"
|
||||||
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -260,13 +274,13 @@ export default function SettingsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Plan info - hidden on mobile, shown in content area instead */}
|
{/* Plan info - hidden on mobile, shown in content area instead */}
|
||||||
<div className="hidden lg:block mt-4 p-5 bg-accent/5 border border-accent/20 rounded-2xl">
|
<div className="hidden lg:block mt-5 p-6 bg-accent/5 border border-accent/20 rounded-2xl">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
{isProOrHigher ? <Crown className="w-4 h-4 text-accent" /> : <Zap className="w-4 h-4 text-accent" />}
|
{isProOrHigher ? <Crown className="w-5 h-5 text-accent" /> : <Zap className="w-5 h-5 text-accent" />}
|
||||||
<span className="text-body-sm font-medium text-foreground">{tierName} Plan</span>
|
<span className="text-sm font-semibold text-foreground">{tierName} Plan</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-body-xs text-foreground-muted mb-3">
|
<p className="text-xs text-foreground-muted mb-4">
|
||||||
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains
|
{subscription?.domains_used || 0} / {subscription?.domain_limit || 5} domains tracked
|
||||||
</p>
|
</p>
|
||||||
{!isProOrHigher && (
|
{!isProOrHigher && (
|
||||||
<Link
|
<Link
|
||||||
@ -495,7 +509,18 @@ export default function SettingsPage() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="text-body-sm font-medium text-foreground">Plan Features</h3>
|
<h3 className="text-body-sm font-medium text-foreground">Plan Features</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{subscription?.features && Object.entries(subscription.features).map(([key, value]) => (
|
{subscription?.features && Object.entries(subscription.features)
|
||||||
|
.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">
|
<li key={key} className="flex items-center gap-2 text-body-sm">
|
||||||
{value ? (
|
{value ? (
|
||||||
<Check className="w-4 h-4 text-accent" />
|
<Check className="w-4 h-4 text-accent" />
|
||||||
@ -503,10 +528,34 @@ export default function SettingsPage() {
|
|||||||
<span className="w-4 h-4 text-foreground-subtle">—</span>
|
<span className="w-4 h-4 text-foreground-subtle">—</span>
|
||||||
)}
|
)}
|
||||||
<span className={value ? 'text-foreground' : 'text-foreground-muted'}>
|
<span className={value ? 'text-foreground' : 'text-foreground-muted'}>
|
||||||
{key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
{featureNames[key] || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -391,9 +391,12 @@ function DomainResultCard({
|
|||||||
export default function TldDetailPage() {
|
export default function TldDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore()
|
const { isAuthenticated, checkAuth, isLoading: authLoading, subscription, fetchSubscription } = useStore()
|
||||||
const tld = params.tld as string
|
const tld = params.tld as string
|
||||||
|
|
||||||
|
// Feature flags based on subscription
|
||||||
|
const hasPriceHistory = (subscription?.history_days ?? 0) !== 0
|
||||||
|
|
||||||
const [details, setDetails] = useState<TldDetails | null>(null)
|
const [details, setDetails] = useState<TldDetails | null>(null)
|
||||||
const [history, setHistory] = useState<TldHistory | null>(null)
|
const [history, setHistory] = useState<TldHistory | null>(null)
|
||||||
const [relatedTlds, setRelatedTlds] = useState<Array<{ tld: string; price: number }>>([])
|
const [relatedTlds, setRelatedTlds] = useState<Array<{ tld: string; price: number }>>([])
|
||||||
@ -408,7 +411,8 @@ export default function TldDetailPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuth()
|
checkAuth()
|
||||||
}, [checkAuth])
|
fetchSubscription()
|
||||||
|
}, [checkAuth, fetchSubscription])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tld) {
|
if (tld) {
|
||||||
@ -797,8 +801,13 @@ export default function TldDetailPage() {
|
|||||||
{/* Price Chart */}
|
{/* Price Chart */}
|
||||||
<section className="mb-12">
|
<section className="mb-12">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-body-lg font-medium text-foreground">Price History</h2>
|
<h2 className="text-body-lg font-medium text-foreground">Price History</h2>
|
||||||
{isAuthenticated && (
|
{isAuthenticated && !hasPriceHistory && (
|
||||||
|
<span className="text-ui-xs px-2 py-0.5 rounded-md bg-accent/10 text-accent">Pro</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hasPriceHistory && (
|
||||||
<div className="flex items-center gap-1 p-1 bg-background-secondary/50 border border-border/50 rounded-lg">
|
<div className="flex items-center gap-1 p-1 bg-background-secondary/50 border border-border/50 rounded-lg">
|
||||||
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => (
|
{(['1M', '3M', '1Y', 'ALL'] as ChartPeriod[]).map(period => (
|
||||||
<button
|
<button
|
||||||
@ -819,13 +828,38 @@ export default function TldDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
<div className="p-6 bg-background-secondary/30 border border-border/50 rounded-2xl">
|
||||||
|
{!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>
|
||||||
|
</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
|
<PriceChart
|
||||||
data={filteredHistory}
|
data={filteredHistory}
|
||||||
isAuthenticated={isAuthenticated}
|
isAuthenticated={true}
|
||||||
chartStats={chartStats}
|
chartStats={chartStats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAuthenticated && filteredHistory.length > 0 && (
|
{filteredHistory.length > 0 && (
|
||||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border/30 text-ui-sm">
|
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border/30 text-ui-sm">
|
||||||
<span className="text-foreground-subtle">
|
<span className="text-foreground-subtle">
|
||||||
{new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
{new Date(filteredHistory[0]?.date).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||||
@ -843,6 +877,8 @@ export default function TldDetailPage() {
|
|||||||
<span className="text-foreground-subtle">Today</span>
|
<span className="text-foreground-subtle">Today</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -258,26 +258,31 @@ export default function TldPricingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background relative flex flex-col">
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
{/* Ambient glow */}
|
{/* Background Effects - matching landing page */}
|
||||||
<div className="fixed inset-0 pointer-events-none">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="absolute top-0 left-1/4 w-[500px] h-[400px] bg-accent/[0.02] rounded-full blur-3xl" />
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '64px 64px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="relative pt-28 sm:pt-32 pb-16 sm:pb-20 px-4 sm:px-6 flex-1">
|
<main className="relative pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6 flex-1">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-12 sm:mb-16 animate-fade-in">
|
<div className="text-center mb-16 sm:mb-20 animate-fade-in">
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-background-secondary/80 backdrop-blur-sm border border-border rounded-full mb-6">
|
<span className="text-sm font-semibold text-accent uppercase tracking-wider">Market Intel</span>
|
||||||
<BarChart3 className="w-4 h-4 text-accent" />
|
<h1 className="mt-4 font-display text-[2.5rem] sm:text-[3.5rem] md:text-[4.5rem] lg:text-[5rem] leading-[0.95] tracking-[-0.03em] text-foreground">
|
||||||
<span className="text-ui-sm text-foreground-muted">Market Intel</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="font-display text-[2rem] sm:text-[2.75rem] md:text-[3.5rem] lg:text-[4rem] leading-[1.1] tracking-[-0.03em] text-foreground mb-4">
|
|
||||||
{pagination.total}+ TLDs. Live Prices.
|
{pagination.total}+ TLDs. Live Prices.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-body sm:text-body-lg text-foreground-muted max-w-2xl mx-auto">
|
<p className="mt-5 text-lg sm:text-xl text-foreground-muted max-w-2xl mx-auto">
|
||||||
See what domains cost. Spot trends. Find opportunities.
|
See what domains cost. Spot trends. Find opportunities.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,32 +1,38 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react'
|
import { useEffect, useState, Suspense } from 'react'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import Link from 'next/link'
|
||||||
import { Header } from '@/components/Header'
|
import { Header } from '@/components/Header'
|
||||||
import { Footer } from '@/components/Footer'
|
import { Footer } from '@/components/Footer'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { Mail, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
|
import { CheckCircle, XCircle, Loader2, Mail, ArrowRight } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
|
||||||
|
|
||||||
function VerifyEmailContent() {
|
function VerifyEmailContent() {
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
|
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'no-token'>('loading')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [message, setMessage] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [resending, setResending] = useState(false)
|
||||||
|
const [resendSuccess, setResendSuccess] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setStatus('error')
|
setStatus('no-token')
|
||||||
setError('Invalid or missing verification token.')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyEmail()
|
||||||
|
}, [token])
|
||||||
|
|
||||||
const verifyEmail = async () => {
|
const verifyEmail = async () => {
|
||||||
try {
|
try {
|
||||||
await api.verifyEmail(token)
|
const response = await api.verifyEmail(token!)
|
||||||
setStatus('success')
|
setStatus('success')
|
||||||
|
setMessage(response.message)
|
||||||
|
|
||||||
// Redirect to login after 3 seconds
|
// Redirect to login after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -34,95 +40,179 @@ function VerifyEmailContent() {
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
setError(err.message || 'Failed to verify email. The link may have expired.')
|
setMessage(err.message || 'Verification failed. The link may be expired.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyEmail()
|
const handleResend = async (e: React.FormEvent) => {
|
||||||
}, [token, router])
|
e.preventDefault()
|
||||||
|
if (!email) return
|
||||||
|
|
||||||
|
setResending(true)
|
||||||
|
try {
|
||||||
|
await api.resendVerification(email)
|
||||||
|
setResendSuccess(true)
|
||||||
|
} catch (err) {
|
||||||
|
// Always show success for security
|
||||||
|
setResendSuccess(true)
|
||||||
|
} finally {
|
||||||
|
setResending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="min-h-screen bg-background relative overflow-hidden">
|
||||||
<Header />
|
{/* Background Effects */}
|
||||||
<main className="min-h-screen flex items-center justify-center p-4 pt-24">
|
<div className="fixed inset-0 pointer-events-none">
|
||||||
<div className="w-full max-w-md">
|
<div className="absolute top-[-20%] left-1/2 -translate-x-1/2 w-[1200px] h-[800px] bg-accent/[0.03] rounded-full blur-[120px]" />
|
||||||
{status === 'loading' && (
|
<div className="absolute bottom-[-10%] right-[-10%] w-[600px] h-[600px] bg-accent/[0.02] rounded-full blur-[100px]" />
|
||||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
<div
|
||||||
<div className="w-16 h-16 bg-accent/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
className="absolute inset-0 opacity-[0.015]"
|
||||||
<Loader2 className="w-8 h-8 text-accent animate-spin" />
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '64px 64px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
|
||||||
Verifying your email...
|
<Header />
|
||||||
</h1>
|
|
||||||
<p className="text-foreground-muted">
|
<main className="relative flex-1 pt-32 sm:pt-40 pb-20 sm:pb-28 px-4 sm:px-6">
|
||||||
Please wait while we verify your email address.
|
<div className="max-w-md mx-auto">
|
||||||
</p>
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-accent/10 border border-accent/20 rounded-full mb-6">
|
||||||
|
<Mail className="w-4 h-4 text-accent" />
|
||||||
|
<span className="text-sm font-medium text-accent">Email Verification</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 backdrop-blur-sm">
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Loader2 className="w-12 h-12 text-accent animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-lg text-foreground">Verifying your email...</p>
|
||||||
|
<p className="text-sm text-foreground-muted mt-2">This will only take a moment</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'success' && (
|
{status === 'success' && (
|
||||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
<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-6">
|
<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" />
|
<CheckCircle className="w-8 h-8 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
<h2 className="text-2xl font-display text-foreground mb-2">Email Verified!</h2>
|
||||||
Email verified!
|
<p className="text-foreground-muted mb-6">{message}</p>
|
||||||
</h1>
|
<p className="text-sm text-foreground-subtle">Redirecting to login...</p>
|
||||||
<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"
|
|
||||||
>
|
|
||||||
Go to login
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<div className="bg-background-secondary/50 border border-border rounded-2xl p-8 text-center">
|
<div className="text-center py-8">
|
||||||
<div className="w-16 h-16 bg-danger/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<AlertCircle className="w-8 h-8 text-danger" />
|
<XCircle className="w-8 h-8 text-red-500" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-display-sm font-bold text-foreground mb-4">
|
<h2 className="text-2xl font-display text-foreground mb-2">Verification Failed</h2>
|
||||||
Verification failed
|
<p className="text-foreground-muted mb-6">{message}</p>
|
||||||
</h1>
|
|
||||||
<p className="text-foreground-muted mb-6">
|
{!resendSuccess ? (
|
||||||
{error}
|
<form onSubmit={handleResend} className="mt-6">
|
||||||
|
<p className="text-sm text-foreground-muted mb-4">
|
||||||
|
Need a new verification link?
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-3">
|
<div className="flex gap-2">
|
||||||
<Link
|
<input
|
||||||
href="/login"
|
type="email"
|
||||||
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"
|
value={email}
|
||||||
>
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
Go to login
|
placeholder="Enter your email"
|
||||||
</Link>
|
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
|
<button
|
||||||
onClick={() => window.location.reload()}
|
type="submit"
|
||||||
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"
|
disabled={resending}
|
||||||
|
className="px-4 py-3 bg-accent text-background rounded-xl font-medium hover:bg-accent-hover transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Try again
|
{resending ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Resend'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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"
|
||||||
|
>
|
||||||
|
Back to Login
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VerifyEmailPage() {
|
export default function VerifyEmailPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<main className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="animate-pulse text-foreground-muted">Loading...</div>
|
<Loader2 className="w-6 h-6 animate-spin text-accent" />
|
||||||
</main>
|
</div>
|
||||||
}>
|
}>
|
||||||
<VerifyEmailContent />
|
<VerifyEmailContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 Link from 'next/link'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Github, Twitter, Mail } from 'lucide-react'
|
import { Github, Twitter, Mail } from 'lucide-react'
|
||||||
|
import { useStore } from '@/lib/store'
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
|
const { isAuthenticated } = useStore()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="relative border-t border-border bg-background-secondary/30 backdrop-blur-sm mt-auto">
|
<footer className="relative border-t border-border bg-background-secondary/30 backdrop-blur-sm mt-auto">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16">
|
||||||
@ -10,6 +15,7 @@ export function Footer() {
|
|||||||
{/* Brand */}
|
{/* Brand */}
|
||||||
<div className="col-span-2 md:col-span-1">
|
<div className="col-span-2 md:col-span-1">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
<Link href="/" className="inline-block">
|
||||||
<Image
|
<Image
|
||||||
src="/pounce-logo.png"
|
src="/pounce-logo.png"
|
||||||
alt="pounce"
|
alt="pounce"
|
||||||
@ -17,6 +23,7 @@ export function Footer() {
|
|||||||
height={60}
|
height={60}
|
||||||
className="w-28 h-auto"
|
className="w-28 h-auto"
|
||||||
/>
|
/>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-body-sm text-foreground-muted mb-4 max-w-xs">
|
<p className="text-body-sm text-foreground-muted mb-4 max-w-xs">
|
||||||
Domain intelligence for hunters. Track. Alert. Pounce.
|
Domain intelligence for hunters. Track. Alert. Pounce.
|
||||||
@ -27,6 +34,7 @@ export function Footer() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
||||||
|
aria-label="GitHub"
|
||||||
>
|
>
|
||||||
<Github className="w-4 h-4 text-foreground-muted" />
|
<Github className="w-4 h-4 text-foreground-muted" />
|
||||||
</a>
|
</a>
|
||||||
@ -35,59 +43,63 @@ export function Footer() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
||||||
|
aria-label="Twitter"
|
||||||
>
|
>
|
||||||
<Twitter className="w-4 h-4 text-foreground-muted" />
|
<Twitter className="w-4 h-4 text-foreground-muted" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="mailto:support@pounce.dev"
|
href="mailto:support@pounce.dev"
|
||||||
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
className="w-9 h-9 flex items-center justify-center rounded-lg bg-background-tertiary hover:bg-background-secondary transition-colors"
|
||||||
|
aria-label="Email"
|
||||||
>
|
>
|
||||||
<Mail className="w-4 h-4 text-foreground-muted" />
|
<Mail className="w-4 h-4 text-foreground-muted" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product */}
|
{/* Product - Matches Header nav */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-ui font-medium text-foreground mb-4">Product</h3>
|
<h3 className="text-ui font-medium text-foreground mb-4">Product</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Domain Monitoring
|
TLD Prices
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/tld-pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/auctions" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||||
TLD Pricing
|
Auctions
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/pricing" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Pricing Plans
|
Pricing
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
{isAuthenticated && (
|
||||||
<li>
|
<li>
|
||||||
<Link href="/dashboard" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/dashboard" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Dashboard
|
Command Center
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Company */}
|
{/* Resources */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-ui font-medium text-foreground mb-4">Company</h3>
|
<h3 className="text-ui font-medium text-foreground mb-4">Resources</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
<li>
|
|
||||||
<Link href="/about" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<Link href="/blog" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/blog" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/about" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/contact" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
<Link href="/contact" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
||||||
Contact
|
Contact
|
||||||
@ -120,11 +132,6 @@ export function Footer() {
|
|||||||
Imprint
|
Imprint
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Link href="/unsubscribe" className="text-body-sm text-foreground-muted hover:text-foreground transition-colors">
|
|
||||||
Unsubscribe
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -150,5 +157,3 @@ export function Footer() {
|
|||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import { useStore } from '@/lib/store'
|
import { useStore } from '@/lib/store'
|
||||||
import { LogOut, LayoutDashboard, Menu, X, Settings, Bell, User, ChevronDown, TrendingUp, Briefcase, Eye } from 'lucide-react'
|
import {
|
||||||
|
LogOut,
|
||||||
|
LayoutDashboard,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Settings,
|
||||||
|
Bell,
|
||||||
|
User,
|
||||||
|
ChevronDown,
|
||||||
|
TrendingUp,
|
||||||
|
Gavel,
|
||||||
|
CreditCard,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
} from 'lucide-react'
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
const pathname = usePathname()
|
||||||
const { isAuthenticated, user, logout, domains, subscription } = useStore()
|
const { isAuthenticated, user, logout, domains, subscription } = useStore()
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||||
@ -28,18 +44,35 @@ export function Header() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Close mobile menu on route change
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false)
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
// Count notifications (available domains, etc.)
|
// Count notifications (available domains, etc.)
|
||||||
const availableDomains = domains?.filter(d => d.is_available) || []
|
const availableDomains = domains?.filter(d => d.is_available) || []
|
||||||
const hasNotifications = availableDomains.length > 0
|
const hasNotifications = availableDomains.length > 0
|
||||||
|
|
||||||
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
const tierName = subscription?.tier_name || subscription?.tier || 'Scout'
|
||||||
|
|
||||||
|
// Navigation items - consistent for logged in/out
|
||||||
|
const navItems = [
|
||||||
|
{ href: '/tld-pricing', label: 'TLD Prices', icon: TrendingUp },
|
||||||
|
{ href: '/auctions', label: 'Auctions', icon: Gavel },
|
||||||
|
{ href: '/pricing', label: 'Pricing', icon: CreditCard },
|
||||||
|
]
|
||||||
|
|
||||||
|
const isActive = (href: string) => {
|
||||||
|
if (href === '/') return pathname === '/'
|
||||||
|
return pathname.startsWith(href)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border-subtle">
|
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border-subtle">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 sm:h-20 flex items-center justify-between">
|
<div className="w-full px-4 sm:px-6 lg:px-8 h-16 sm:h-20 flex items-center justify-between">
|
||||||
{/* Left side: Logo + Nav Links */}
|
{/* Left side: Logo + Nav Links */}
|
||||||
<div className="flex items-center gap-6 sm:gap-8 h-full">
|
<div className="flex items-center gap-6 sm:gap-8 h-full">
|
||||||
{/* Logo - Playfair Display font */}
|
{/* Logo */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="flex items-center h-full hover:opacity-80 transition-opacity duration-300"
|
className="flex items-center h-full hover:opacity-80 transition-opacity duration-300"
|
||||||
@ -52,53 +85,41 @@ export function Header() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Left Nav Links (Desktop) - vertically centered */}
|
{/* Main Nav Links (Desktop) */}
|
||||||
<nav className="hidden sm:flex items-center h-full gap-1">
|
<nav className="hidden md:flex items-center h-full gap-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
key={item.href}
|
||||||
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
href={item.href}
|
||||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
className={clsx(
|
||||||
>
|
"flex items-center h-9 px-3 text-[0.8125rem] rounded-lg transition-all duration-200",
|
||||||
Domain
|
isActive(item.href)
|
||||||
</Link>
|
? "text-foreground bg-foreground/5 font-medium"
|
||||||
<Link
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
href="/tld-pricing"
|
|
||||||
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
|
||||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
|
||||||
>
|
|
||||||
TLD
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/auctions"
|
|
||||||
className="flex items-center h-9 px-3 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
|
||||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
|
||||||
>
|
|
||||||
Auctions
|
|
||||||
</Link>
|
|
||||||
{!isAuthenticated && (
|
|
||||||
<Link
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Plans
|
|
||||||
</Link>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: Auth Links - vertically centered */}
|
{/* Right side */}
|
||||||
<nav className="hidden sm:flex items-center h-full gap-2">
|
<nav className="hidden sm:flex items-center h-full gap-2">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
{/* Dashboard Link */}
|
{/* Command Center Link - Primary CTA when logged in */}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="flex items-center gap-2 h-9 px-4 text-[0.8125rem] font-medium text-foreground
|
className={clsx(
|
||||||
bg-foreground/5 hover:bg-foreground/10 rounded-lg transition-all duration-300"
|
"flex items-center gap-2 h-9 px-4 text-[0.8125rem] font-medium rounded-lg transition-all duration-200",
|
||||||
|
isActive('/dashboard')
|
||||||
|
? "bg-foreground text-background"
|
||||||
|
: "text-foreground bg-foreground/5 hover:bg-foreground/10"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
<span>Dashboard</span>
|
<span>Command Center</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
@ -106,8 +127,10 @@ export function Header() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
onClick={() => setNotificationsOpen(!notificationsOpen)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300",
|
"relative flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-200",
|
||||||
notificationsOpen ? "bg-foreground/10 text-foreground" : "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
notificationsOpen
|
||||||
|
? "bg-foreground/10 text-foreground"
|
||||||
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
@ -127,7 +150,7 @@ export function Header() {
|
|||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{availableDomains.length > 0 ? (
|
{availableDomains.length > 0 ? (
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{availableDomains.map((domain) => (
|
{availableDomains.slice(0, 5).map((domain) => (
|
||||||
<Link
|
<Link
|
||||||
key={domain.id}
|
key={domain.id}
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
@ -135,14 +158,19 @@ export function Header() {
|
|||||||
className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors"
|
className="flex items-start gap-3 p-3 hover:bg-foreground/5 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center shrink-0">
|
<div className="w-8 h-8 bg-accent/10 rounded-lg flex items-center justify-center shrink-0">
|
||||||
<Eye className="w-4 h-4 text-accent" />
|
<Search className="w-4 h-4 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-body-sm font-medium text-foreground truncate">{domain.name}</p>
|
<p className="text-body-sm font-medium text-foreground truncate">{domain.name}</p>
|
||||||
<p className="text-body-xs text-accent">Domain is available!</p>
|
<p className="text-body-xs text-accent">Available now!</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{availableDomains.length > 5 && (
|
||||||
|
<p className="px-3 py-2 text-body-xs text-foreground-muted">
|
||||||
|
+{availableDomains.length - 5} more available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
@ -157,7 +185,7 @@ export function Header() {
|
|||||||
onClick={() => setNotificationsOpen(false)}
|
onClick={() => setNotificationsOpen(false)}
|
||||||
className="block p-3 text-center text-body-xs text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-t border-border transition-colors"
|
className="block p-3 text-center text-body-xs text-foreground-muted hover:text-foreground hover:bg-foreground/5 border-t border-border transition-colors"
|
||||||
>
|
>
|
||||||
Manage notifications
|
Notification settings
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -168,14 +196,17 @@ export function Header() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"flex items-center gap-2 h-9 pl-3 pr-2 rounded-lg transition-all duration-300",
|
"flex items-center gap-2 h-9 pl-3 pr-2 rounded-lg transition-all duration-200",
|
||||||
userMenuOpen ? "bg-foreground/10" : "hover:bg-foreground/5"
|
userMenuOpen ? "bg-foreground/10" : "hover:bg-foreground/5"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-6 h-6 bg-accent/10 rounded-full flex items-center justify-center">
|
<div className="w-6 h-6 bg-accent/10 rounded-full flex items-center justify-center">
|
||||||
<User className="w-3.5 h-3.5 text-accent" />
|
<User className="w-3.5 h-3.5 text-accent" />
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className={clsx("w-3.5 h-3.5 text-foreground-muted transition-transform", userMenuOpen && "rotate-180")} />
|
<ChevronDown className={clsx(
|
||||||
|
"w-3.5 h-3.5 text-foreground-muted transition-transform duration-200",
|
||||||
|
userMenuOpen && "rotate-180"
|
||||||
|
)} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* User Dropdown */}
|
{/* User Dropdown */}
|
||||||
@ -186,29 +217,23 @@ export function Header() {
|
|||||||
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
||||||
<p className="text-body-xs text-foreground-muted truncate">{user?.email}</p>
|
<p className="text-body-xs text-foreground-muted truncate">{user?.email}</p>
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<span className="text-ui-xs px-2 py-0.5 bg-foreground/5 text-foreground-muted rounded-full">{tierName}</span>
|
<span className="text-ui-xs px-2 py-0.5 bg-accent/10 text-accent rounded-full font-medium">{tierName}</span>
|
||||||
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
|
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
|
{user?.is_admin && (
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/admin"
|
||||||
onClick={() => setUserMenuOpen(false)}
|
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"
|
className="flex items-center gap-3 px-3 py-2.5 text-body-sm text-accent hover:bg-accent/10 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
Command Center
|
Admin Panel
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/tld-pricing"
|
|
||||||
onClick={() => setUserMenuOpen(false)}
|
|
||||||
className="flex items-center gap-3 px-3 py-2.5 text-body-sm text-foreground-muted hover:text-foreground hover:bg-foreground/5 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<TrendingUp className="w-4 h-4" />
|
|
||||||
TLD Pricing
|
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={() => setUserMenuOpen(false)}
|
onClick={() => setUserMenuOpen(false)}
|
||||||
@ -241,14 +266,14 @@ export function Header() {
|
|||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="flex items-center h-9 px-4 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
className="flex items-center h-9 px-4 text-[0.8125rem] text-foreground-muted hover:text-foreground
|
||||||
hover:bg-background-secondary rounded-lg transition-all duration-300"
|
hover:bg-foreground/5 rounded-lg transition-all duration-200"
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="flex items-center h-9 ml-2 px-5 text-[0.8125rem] bg-foreground text-background rounded-lg
|
className="flex items-center h-9 ml-1 px-5 text-[0.8125rem] bg-foreground text-background rounded-lg
|
||||||
font-medium hover:bg-foreground/90 transition-all duration-300"
|
font-medium hover:bg-foreground/90 transition-all duration-200"
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</Link>
|
</Link>
|
||||||
@ -268,54 +293,71 @@ export function Header() {
|
|||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
{mobileMenuOpen && (
|
{mobileMenuOpen && (
|
||||||
<div className="sm:hidden border-t border-border bg-background/95 backdrop-blur-xl">
|
<div className="sm:hidden border-t border-border bg-background/95 backdrop-blur-xl">
|
||||||
<nav className="px-4 py-4 space-y-2">
|
<nav className="px-4 py-4 space-y-1">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated && (
|
||||||
<>
|
<>
|
||||||
{/* User Info on Mobile */}
|
{/* User Info on Mobile */}
|
||||||
<div className="px-4 py-3 mb-2 border-b border-border">
|
<div className="px-4 py-3 mb-3 bg-foreground/5 rounded-xl">
|
||||||
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
<p className="text-body-sm font-medium text-foreground truncate">{user?.name || user?.email}</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<span className="text-ui-xs px-2 py-0.5 bg-foreground/5 text-foreground-muted rounded-full">{tierName}</span>
|
<span className="text-ui-xs px-2 py-0.5 bg-accent/10 text-accent rounded-full font-medium">{tierName}</span>
|
||||||
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
|
<span className="text-ui-xs text-foreground-subtle">{subscription?.domains_used || 0}/{subscription?.domain_limit || 5} domains</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
|
className={clsx(
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
"flex items-center gap-3 px-4 py-3 text-body-sm rounded-xl transition-all duration-200",
|
||||||
transition-all duration-300"
|
isActive('/dashboard')
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
? "bg-foreground text-background font-medium"
|
||||||
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<LayoutDashboard className="w-5 h-5" />
|
<LayoutDashboard className="w-5 h-5" />
|
||||||
<span>Dashboard</span>
|
<span>Command Center</span>
|
||||||
|
{hasNotifications && (
|
||||||
|
<span className="ml-auto w-2 h-2 bg-accent rounded-full" />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Nav */}
|
||||||
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
href="/tld-pricing"
|
key={item.href}
|
||||||
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
|
href={item.href}
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
className={clsx(
|
||||||
transition-all duration-300"
|
"flex items-center gap-3 px-4 py-3 text-body-sm rounded-xl transition-all duration-200",
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
isActive(item.href)
|
||||||
|
? "bg-foreground/10 text-foreground font-medium"
|
||||||
|
: "text-foreground-muted hover:text-foreground hover:bg-foreground/5"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<TrendingUp className="w-5 h-5" />
|
<item.icon className="w-5 h-5" />
|
||||||
<span>TLD Pricing</span>
|
<span>{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<>
|
||||||
|
<div className="my-3 border-t border-border" />
|
||||||
|
{user?.is_admin && (
|
||||||
<Link
|
<Link
|
||||||
href="/auctions"
|
href="/admin"
|
||||||
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)}
|
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"
|
||||||
>
|
>
|
||||||
<Briefcase className="w-5 h-5" />
|
<Shield className="w-5 h-5" />
|
||||||
<span>Auctions</span>
|
<span>Admin Panel</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
|
className="flex items-center gap-3 px-4 py-3 text-body-sm text-foreground-muted
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||||
transition-all duration-300"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
>
|
||||||
<Settings className="w-5 h-5" />
|
<Settings className="w-5 h-5" />
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
@ -326,8 +368,7 @@ export function Header() {
|
|||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false)
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-3 w-full px-4 py-3 text-body-sm text-foreground-muted
|
className="flex items-center gap-3 w-full px-4 py-3 text-body-sm text-foreground-muted
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||||
transition-all duration-300"
|
|
||||||
>
|
>
|
||||||
<LogOut className="w-5 h-5" />
|
<LogOut className="w-5 h-5" />
|
||||||
<span>Sign Out</span>
|
<span>Sign Out</span>
|
||||||
@ -335,56 +376,18 @@ export function Header() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link
|
<div className="my-3 border-t border-border" />
|
||||||
href="/"
|
|
||||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
|
||||||
transition-all duration-300"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Domain
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/tld-pricing"
|
|
||||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
|
||||||
transition-all duration-300"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
TLD
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/auctions"
|
|
||||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
|
||||||
transition-all duration-300"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Auctions
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/pricing"
|
|
||||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
|
||||||
transition-all duration-300"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
|
||||||
Plans
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="block px-4 py-3 text-body-sm text-foreground-muted
|
className="block px-4 py-3 text-body-sm text-foreground-muted
|
||||||
hover:text-foreground hover:bg-background-secondary rounded-xl
|
hover:text-foreground hover:bg-foreground/5 rounded-xl transition-all duration-200"
|
||||||
transition-all duration-300"
|
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
>
|
||||||
Sign In
|
Sign In
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="block px-4 py-3 text-body-sm text-center bg-foreground text-background
|
className="block px-4 py-3 text-body-sm text-center bg-foreground text-background
|
||||||
rounded-xl font-medium hover:bg-foreground/90 transition-all duration-300"
|
rounded-xl font-medium hover:bg-foreground/90 transition-all duration-200"
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
|
||||||
>
|
>
|
||||||
Get Started
|
Get Started
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
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) {
|
if (!response.ok) {
|
||||||
const error: ApiError = await response.json().catch(() => ({ detail: 'An error occurred' }))
|
const errorData = await response.json().catch(() => ({ detail: 'An error occurred' }))
|
||||||
throw new Error(error.detail)
|
const errorMessage = typeof errorData.detail === 'string'
|
||||||
|
? errorData.detail
|
||||||
|
: (errorData.message || JSON.stringify(errorData.detail) || 'An error occurred')
|
||||||
|
throw new Error(errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
@ -126,6 +129,8 @@ class ApiClient {
|
|||||||
email: string
|
email: string
|
||||||
name: string | null
|
name: string | null
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
|
is_admin: boolean
|
||||||
|
is_verified: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
}>('/auth/me')
|
}>('/auth/me')
|
||||||
}
|
}
|
||||||
@ -173,6 +178,21 @@ class ApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth
|
||||||
|
async getOAuthProviders() {
|
||||||
|
return this.request<{ google_enabled: boolean; github_enabled: boolean }>('/oauth/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
getGoogleLoginUrl(redirect?: string) {
|
||||||
|
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
|
||||||
|
return `${this.baseUrl}/oauth/google/login${params}`
|
||||||
|
}
|
||||||
|
|
||||||
|
getGitHubLoginUrl(redirect?: string) {
|
||||||
|
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
|
||||||
|
return `${this.baseUrl}/oauth/github/login${params}`
|
||||||
|
}
|
||||||
|
|
||||||
// Contact Form
|
// Contact Form
|
||||||
async submitContact(name: string, email: string, subject: string, message: string) {
|
async submitContact(name: string, email: string, subject: string, message: string) {
|
||||||
return this.request<{ message: string; success: boolean }>('/contact', {
|
return this.request<{ message: string; success: boolean }>('/contact', {
|
||||||
@ -308,6 +328,7 @@ class ApiClient {
|
|||||||
status: string
|
status: string
|
||||||
domain_limit: number
|
domain_limit: number
|
||||||
domains_used: number
|
domains_used: number
|
||||||
|
portfolio_limit: number
|
||||||
check_frequency: string
|
check_frequency: string
|
||||||
history_days: number
|
history_days: number
|
||||||
features: {
|
features: {
|
||||||
@ -315,8 +336,12 @@ class ApiClient {
|
|||||||
priority_alerts: boolean
|
priority_alerts: boolean
|
||||||
full_whois: boolean
|
full_whois: boolean
|
||||||
expiration_tracking: boolean
|
expiration_tracking: boolean
|
||||||
|
domain_valuation: boolean
|
||||||
|
market_insights: boolean
|
||||||
api_access: boolean
|
api_access: boolean
|
||||||
webhooks: boolean
|
webhooks: boolean
|
||||||
|
bulk_tools: boolean
|
||||||
|
seo_metrics: boolean
|
||||||
}
|
}
|
||||||
started_at: string
|
started_at: string
|
||||||
expires_at: string | null
|
expires_at: string | null
|
||||||
@ -517,7 +542,7 @@ class ApiClient {
|
|||||||
maxBid?: number,
|
maxBid?: number,
|
||||||
endingSoon = false,
|
endingSoon = false,
|
||||||
sortBy = 'ending',
|
sortBy = 'ending',
|
||||||
limit = 20,
|
limit = 100,
|
||||||
offset = 0
|
offset = 0
|
||||||
) {
|
) {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@ -824,6 +849,15 @@ class AdminApiClient extends ApiClient {
|
|||||||
return this.request<any>('/admin/tld-prices/stats')
|
return this.request<any>('/admin/tld-prices/stats')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auction Scraping
|
||||||
|
async triggerAuctionScrape() {
|
||||||
|
return this.request<any>('/auctions/admin/scrape', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuctionScrapeStatus() {
|
||||||
|
return this.request<any>('/auctions/admin/scrape-status')
|
||||||
|
}
|
||||||
|
|
||||||
// System
|
// System
|
||||||
async getSystemHealth() {
|
async getSystemHealth() {
|
||||||
return this.request<any>('/admin/system/health')
|
return this.request<any>('/admin/system/health')
|
||||||
@ -832,6 +866,239 @@ class AdminApiClient extends ApiClient {
|
|||||||
async makeUserAdmin(email: string) {
|
async makeUserAdmin(email: string) {
|
||||||
return this.request<any>(`/admin/system/make-admin?email=${encodeURIComponent(email)}`, { method: 'POST' })
|
return this.request<any>(`/admin/system/make-admin?email=${encodeURIComponent(email)}`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Price Alerts
|
||||||
|
async getAdminPriceAlerts(limit = 100, offset = 0) {
|
||||||
|
return this.request<{
|
||||||
|
alerts: Array<{
|
||||||
|
id: number
|
||||||
|
tld: string
|
||||||
|
target_price: number | null
|
||||||
|
alert_type: string
|
||||||
|
created_at: string
|
||||||
|
user: { id: number; email: string; name: string | null }
|
||||||
|
}>
|
||||||
|
total: number
|
||||||
|
}>(`/admin/price-alerts?limit=${limit}&offset=${offset}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain Health Check
|
||||||
|
async triggerDomainChecks() {
|
||||||
|
return this.request<{
|
||||||
|
message: string
|
||||||
|
domains_queued: number
|
||||||
|
started_at: string
|
||||||
|
}>('/admin/domains/check-all', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email Test
|
||||||
|
async sendTestEmail() {
|
||||||
|
return this.request<{
|
||||||
|
message: string
|
||||||
|
sent_to: string
|
||||||
|
timestamp: string
|
||||||
|
}>('/admin/system/test-email', { method: 'POST' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler Status
|
||||||
|
async getSchedulerStatus() {
|
||||||
|
return this.request<{
|
||||||
|
scheduler_running: boolean
|
||||||
|
jobs: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
next_run: string | null
|
||||||
|
trigger: string
|
||||||
|
}>
|
||||||
|
last_runs: {
|
||||||
|
tld_scrape: string | null
|
||||||
|
auction_scrape: string | null
|
||||||
|
domain_check: string | null
|
||||||
|
}
|
||||||
|
timestamp: string
|
||||||
|
}>('/admin/system/scheduler')
|
||||||
|
}
|
||||||
|
|
||||||
|
// User Export
|
||||||
|
async exportUsersCSV() {
|
||||||
|
return this.request<{
|
||||||
|
csv: string
|
||||||
|
count: number
|
||||||
|
exported_at: string
|
||||||
|
}>('/admin/users/export')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bulk Upgrade
|
||||||
|
async bulkUpgradeUsers(userIds: number[], tier: string) {
|
||||||
|
return this.request<{
|
||||||
|
message: string
|
||||||
|
tier: string
|
||||||
|
upgraded: Array<{ user_id: number; email: string }>
|
||||||
|
failed: Array<{ user_id: number; reason: string }>
|
||||||
|
total_upgraded: number
|
||||||
|
total_failed: number
|
||||||
|
}>('/admin/users/bulk-upgrade', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ user_ids: userIds, tier }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity Log
|
||||||
|
async getActivityLog(limit = 50, offset = 0) {
|
||||||
|
return this.request<{
|
||||||
|
logs: Array<{
|
||||||
|
id: number
|
||||||
|
action: string
|
||||||
|
details: string
|
||||||
|
created_at: string
|
||||||
|
admin: { id: number; email: string; name: string | null }
|
||||||
|
}>
|
||||||
|
total: number
|
||||||
|
}>(`/admin/activity-log?limit=${limit}&offset=${offset}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== Blog ==============
|
||||||
|
|
||||||
|
async getBlogPosts(limit = 10, offset = 0, category?: string, tag?: string) {
|
||||||
|
let url = `/blog/posts?limit=${limit}&offset=${offset}`
|
||||||
|
if (category) url += `&category=${encodeURIComponent(category)}`
|
||||||
|
if (tag) url += `&tag=${encodeURIComponent(tag)}`
|
||||||
|
return this.request<{
|
||||||
|
posts: Array<{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
excerpt: string | null
|
||||||
|
cover_image: string | null
|
||||||
|
category: string | null
|
||||||
|
tags: string[]
|
||||||
|
is_published: boolean
|
||||||
|
published_at: string | null
|
||||||
|
created_at: string
|
||||||
|
view_count: number
|
||||||
|
author: { id: number; name: string | null }
|
||||||
|
}>
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}>(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlogPost(slug: string) {
|
||||||
|
return this.request<{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
excerpt: string | null
|
||||||
|
content: string
|
||||||
|
cover_image: string | null
|
||||||
|
category: string | null
|
||||||
|
tags: string[]
|
||||||
|
meta_title: string | null
|
||||||
|
meta_description: string | null
|
||||||
|
is_published: boolean
|
||||||
|
published_at: string | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
view_count: number
|
||||||
|
author: { id: number; name: string | null }
|
||||||
|
}>(`/blog/posts/${slug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBlogCategories() {
|
||||||
|
return this.request<{
|
||||||
|
categories: Array<{ name: string; count: number }>
|
||||||
|
}>('/blog/posts/categories')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFeaturedPosts(limit = 3) {
|
||||||
|
return this.request<{
|
||||||
|
posts: Array<{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
excerpt: string | null
|
||||||
|
cover_image: string | null
|
||||||
|
category: string | null
|
||||||
|
published_at: string | null
|
||||||
|
}>
|
||||||
|
}>(`/blog/posts/featured?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Blog
|
||||||
|
async getAdminBlogPosts(limit = 50, offset = 0, status?: 'published' | 'draft') {
|
||||||
|
let url = `/blog/admin/posts?limit=${limit}&offset=${offset}`
|
||||||
|
if (status) url += `&status_filter=${status}`
|
||||||
|
return this.request<{
|
||||||
|
posts: Array<{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
excerpt: string | null
|
||||||
|
cover_image: string | null
|
||||||
|
category: string | null
|
||||||
|
tags: string[]
|
||||||
|
is_published: boolean
|
||||||
|
published_at: string | null
|
||||||
|
created_at: string
|
||||||
|
view_count: number
|
||||||
|
author: { id: number; name: string | null }
|
||||||
|
}>
|
||||||
|
total: number
|
||||||
|
}>(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBlogPost(data: {
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
excerpt?: string
|
||||||
|
cover_image?: string
|
||||||
|
category?: string
|
||||||
|
tags?: string[]
|
||||||
|
meta_title?: string
|
||||||
|
meta_description?: string
|
||||||
|
is_published?: boolean
|
||||||
|
}) {
|
||||||
|
return this.request<any>('/blog/admin/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBlogPost(postId: number, data: {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
excerpt?: string
|
||||||
|
cover_image?: string
|
||||||
|
category?: string
|
||||||
|
tags?: string[]
|
||||||
|
meta_title?: string
|
||||||
|
meta_description?: string
|
||||||
|
is_published?: boolean
|
||||||
|
}) {
|
||||||
|
return this.request<any>(`/blog/admin/posts/${postId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteBlogPost(postId: number) {
|
||||||
|
return this.request<any>(`/blog/admin/posts/${postId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishBlogPost(postId: number) {
|
||||||
|
return this.request<any>(`/blog/admin/posts/${postId}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async unpublishBlogPost(postId: number) {
|
||||||
|
return this.request<any>(`/blog/admin/posts/${postId}/unpublish`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new AdminApiClient()
|
export const api = new AdminApiClient()
|
||||||
|
|||||||
@ -8,6 +8,8 @@ interface User {
|
|||||||
id: number
|
id: number
|
||||||
email: string
|
email: string
|
||||||
name: string | null
|
name: string | null
|
||||||
|
is_admin?: boolean
|
||||||
|
is_verified?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Domain {
|
interface Domain {
|
||||||
@ -27,6 +29,7 @@ interface Subscription {
|
|||||||
tier_name?: string
|
tier_name?: string
|
||||||
domain_limit: number
|
domain_limit: number
|
||||||
domains_used: number
|
domains_used: number
|
||||||
|
portfolio_limit?: number
|
||||||
check_frequency?: string
|
check_frequency?: string
|
||||||
history_days?: number
|
history_days?: number
|
||||||
features?: {
|
features?: {
|
||||||
@ -34,8 +37,12 @@ interface Subscription {
|
|||||||
priority_alerts: boolean
|
priority_alerts: boolean
|
||||||
full_whois: boolean
|
full_whois: boolean
|
||||||
expiration_tracking: boolean
|
expiration_tracking: boolean
|
||||||
|
domain_valuation: boolean
|
||||||
|
market_insights: boolean
|
||||||
api_access: boolean
|
api_access: boolean
|
||||||
webhooks: boolean
|
webhooks: boolean
|
||||||
|
bulk_tools: boolean
|
||||||
|
seo_metrics: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,6 +172,7 @@ export const useStore = create<AppState>((set, get) => ({
|
|||||||
tier_name: sub.tier_name,
|
tier_name: sub.tier_name,
|
||||||
domain_limit: sub.domain_limit,
|
domain_limit: sub.domain_limit,
|
||||||
domains_used: sub.domains_used,
|
domains_used: sub.domains_used,
|
||||||
|
portfolio_limit: sub.portfolio_limit,
|
||||||
check_frequency: sub.check_frequency,
|
check_frequency: sub.check_frequency,
|
||||||
history_days: sub.history_days,
|
history_days: sub.history_days,
|
||||||
features: sub.features,
|
features: sub.features,
|
||||||
|
|||||||
Reference in New Issue
Block a user