diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 183879d..63f55f8 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,32 +1,472 @@ -"""Admin API endpoints - for internal use only.""" -from fastapi import APIRouter, HTTPException, status, BackgroundTasks -from pydantic import BaseModel, EmailStr -from sqlalchemy import select +""" +Admin API endpoints for pounce administration. -from app.api.deps import Database +Provides admin-only access to: +- User management +- TLD price scraping +- System statistics +- Newsletter management +- Domain/Portfolio overview +""" +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, HTTPException, status, BackgroundTasks, Depends +from pydantic import BaseModel, EmailStr +from sqlalchemy import select, func, desc + +from app.api.deps import Database, get_current_user from app.models.user import User from app.models.subscription import Subscription, SubscriptionTier, SubscriptionStatus, TIER_CONFIG +from app.models.domain import Domain +from app.models.portfolio import PortfolioDomain +from app.models.newsletter import NewsletterSubscriber +from app.models.tld_price import TLDPrice, TLDInfo +from app.models.auction import DomainAuction +from app.models.price_alert import PriceAlert router = APIRouter() +# ============== Admin Authentication ============== + +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 + + +# ============== Dashboard Stats ============== + +@router.get("/stats") +async def get_admin_stats( + db: Database, + admin: User = Depends(require_admin) +): + """Get comprehensive admin dashboard statistics.""" + + # User stats + total_users = await db.execute(select(func.count(User.id))) + total_users = total_users.scalar() + + active_users = await db.execute( + select(func.count(User.id)).where(User.is_active == True) + ) + active_users = active_users.scalar() + + verified_users = await db.execute( + select(func.count(User.id)).where(User.is_verified == True) + ) + verified_users = verified_users.scalar() + + # New users last 7 days + week_ago = datetime.utcnow() - timedelta(days=7) + new_users_week = await db.execute( + select(func.count(User.id)).where(User.created_at >= week_ago) + ) + new_users_week = new_users_week.scalar() + + # Subscription stats + subscriptions_by_tier = {} + for tier in SubscriptionTier: + count = await db.execute( + select(func.count(Subscription.id)).where( + Subscription.tier == tier, + Subscription.status == SubscriptionStatus.ACTIVE + ) + ) + subscriptions_by_tier[tier.value] = count.scalar() + + # Domain stats + total_domains = await db.execute(select(func.count(Domain.id))) + total_domains = total_domains.scalar() + + total_portfolio = await db.execute(select(func.count(PortfolioDomain.id))) + total_portfolio = total_portfolio.scalar() + + # TLD stats + total_tlds = await db.execute(select(func.count(func.distinct(TLDPrice.tld)))) + total_tlds = total_tlds.scalar() + + total_price_records = await db.execute(select(func.count(TLDPrice.id))) + total_price_records = total_price_records.scalar() + + # Newsletter stats + newsletter_subscribers = await db.execute( + select(func.count(NewsletterSubscriber.id)).where(NewsletterSubscriber.is_active == True) + ) + newsletter_subscribers = newsletter_subscribers.scalar() + + # Auction stats + total_auctions = await db.execute(select(func.count(DomainAuction.id))) + total_auctions = total_auctions.scalar() + + # Price alerts + total_alerts = await db.execute( + select(func.count(PriceAlert.id)).where(PriceAlert.is_active == True) + ) + total_alerts = total_alerts.scalar() + + return { + "users": { + "total": total_users, + "active": active_users, + "verified": verified_users, + "new_this_week": new_users_week, + }, + "subscriptions": subscriptions_by_tier, + "domains": { + "watched": total_domains, + "portfolio": total_portfolio, + }, + "tld_data": { + "unique_tlds": total_tlds, + "price_records": total_price_records, + }, + "newsletter_subscribers": newsletter_subscribers, + "auctions": total_auctions, + "price_alerts": total_alerts, + } + + +# ============== User Management ============== + +class UpdateUserRequest(BaseModel): + """Schema for updating user.""" + name: Optional[str] = None + is_active: Optional[bool] = None + is_verified: Optional[bool] = None + is_admin: Optional[bool] = None + + +class UpgradeUserRequest(BaseModel): + """Request schema for upgrading a user.""" + email: EmailStr + tier: str # scout, trader, tycoon + + +@router.get("/users") +async def list_users( + db: Database, + admin: User = Depends(require_admin), + limit: int = 50, + offset: int = 0, + search: Optional[str] = None, +): + """List all users with pagination and search.""" + query = select(User).order_by(desc(User.created_at)) + + if search: + query = query.where( + User.email.ilike(f"%{search}%") | + User.name.ilike(f"%{search}%") + ) + + query = query.offset(offset).limit(limit) + result = await db.execute(query) + users = result.scalars().all() + + # Get total count + count_query = select(func.count(User.id)) + if search: + count_query = count_query.where( + User.email.ilike(f"%{search}%") | + User.name.ilike(f"%{search}%") + ) + total = await db.execute(count_query) + total = total.scalar() + + user_list = [] + for user in users: + # 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() + + user_list.append({ + "id": user.id, + "email": user.email, + "name": user.name, + "is_active": user.is_active, + "is_verified": user.is_verified, + "is_admin": user.is_admin, + "created_at": user.created_at.isoformat(), + "last_login": user.last_login.isoformat() if user.last_login else None, + "domain_count": domain_count, + "subscription": { + "tier": subscription.tier.value if subscription else "scout", + "tier_name": TIER_CONFIG.get(subscription.tier, {}).get("name", "Scout") if subscription else "Scout", + "status": subscription.status.value if subscription else None, + "domain_limit": subscription.domain_limit if subscription else 5, + } if subscription else { + "tier": "scout", + "tier_name": "Scout", + "status": None, + "domain_limit": 5, + }, + }) + + return { + "users": user_list, + "total": total, + "limit": limit, + "offset": offset, + } + + +@router.get("/users/{user_id}") +async def get_user( + user_id: int, + db: Database, + admin: User = Depends(require_admin), +): + """Get detailed user information.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get subscription + sub_result = await db.execute( + select(Subscription).where(Subscription.user_id == user.id) + ) + subscription = sub_result.scalar_one_or_none() + + # Get domains + domains_result = await db.execute( + select(Domain).where(Domain.user_id == user.id) + ) + domains = domains_result.scalars().all() + + # Get portfolio + portfolio_result = await db.execute( + select(PortfolioDomain).where(PortfolioDomain.user_id == user.id) + ) + portfolio = portfolio_result.scalars().all() + + return { + "id": user.id, + "email": user.email, + "name": user.name, + "is_active": user.is_active, + "is_verified": user.is_verified, + "is_admin": user.is_admin, + "stripe_customer_id": user.stripe_customer_id, + "created_at": user.created_at.isoformat(), + "updated_at": user.updated_at.isoformat(), + "last_login": user.last_login.isoformat() if user.last_login else None, + "subscription": { + "tier": subscription.tier.value if subscription else None, + "status": subscription.status.value if subscription else None, + "domain_limit": subscription.domain_limit if subscription else 0, + "stripe_subscription_id": subscription.stripe_subscription_id if subscription else None, + } if subscription else None, + "domains": [ + { + "id": d.id, + "domain": d.domain, + "status": d.status, + "created_at": d.created_at.isoformat(), + } + for d in domains + ], + "portfolio": [ + { + "id": p.id, + "domain": p.domain, + "purchase_price": p.purchase_price, + "estimated_value": p.estimated_value, + } + for p in portfolio + ], + } + + +@router.patch("/users/{user_id}") +async def update_user( + user_id: int, + request: UpdateUserRequest, + db: Database, + admin: User = Depends(require_admin), +): + """Update user details.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if request.name is not None: + user.name = request.name + if request.is_active is not None: + user.is_active = request.is_active + if request.is_verified is not None: + user.is_verified = request.is_verified + if request.is_admin is not None: + user.is_admin = request.is_admin + + await db.commit() + await db.refresh(user) + + return {"message": f"User {user.email} updated", "user_id": user.id} + + +@router.delete("/users/{user_id}") +async def delete_user( + user_id: int, + db: Database, + admin: User = Depends(require_admin), +): + """Delete a user and all their data.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if user.is_admin: + raise HTTPException(status_code=400, detail="Cannot delete admin user") + + await db.delete(user) + await db.commit() + + return {"message": f"User {user.email} deleted"} + + +@router.post("/users/{user_id}/upgrade") +async def upgrade_user( + user_id: int, + tier: str, + db: Database, + admin: User = Depends(require_admin), +): + """Upgrade a user's subscription tier.""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Validate tier + try: + new_tier = SubscriptionTier(tier) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Invalid tier: {tier}. Valid: scout, trader, tycoon" + ) + + # Find or create subscription + sub_result = await db.execute( + select(Subscription).where(Subscription.user_id == user.id) + ) + subscription = sub_result.scalar_one_or_none() + + config = TIER_CONFIG.get(new_tier, {}) + + if not subscription: + subscription = Subscription( + user_id=user.id, + tier=new_tier, + status=SubscriptionStatus.ACTIVE, + domain_limit=config.get("domain_limit", 5), + ) + db.add(subscription) + else: + subscription.tier = new_tier + subscription.domain_limit = config.get("domain_limit", 5) + subscription.status = SubscriptionStatus.ACTIVE + + await db.commit() + + return { + "message": f"User {user.email} upgraded to {config.get('name', tier)}", + "tier": new_tier.value, + } + + +# ============== Newsletter Management ============== + +@router.get("/newsletter") +async def list_newsletter_subscribers( + db: Database, + admin: User = Depends(require_admin), + limit: int = 100, + offset: int = 0, + active_only: bool = True, +): + """List newsletter subscribers.""" + query = select(NewsletterSubscriber).order_by(desc(NewsletterSubscriber.subscribed_at)) + + if active_only: + query = query.where(NewsletterSubscriber.is_active == True) + + query = query.offset(offset).limit(limit) + result = await db.execute(query) + subscribers = result.scalars().all() + + # Total count + count_query = select(func.count(NewsletterSubscriber.id)) + if active_only: + count_query = count_query.where(NewsletterSubscriber.is_active == True) + total = await db.execute(count_query) + total = total.scalar() + + return { + "subscribers": [ + { + "id": s.id, + "email": s.email, + "is_active": s.is_active, + "subscribed_at": s.subscribed_at.isoformat(), + "unsubscribed_at": s.unsubscribed_at.isoformat() if s.unsubscribed_at else None, + } + for s in subscribers + ], + "total": total, + } + + +@router.get("/newsletter/export") +async def export_newsletter_emails( + db: Database, + admin: User = Depends(require_admin), +): + """Export all active newsletter emails (for external tools).""" + result = await db.execute( + select(NewsletterSubscriber.email).where(NewsletterSubscriber.is_active == True) + ) + emails = [row[0] for row in result.fetchall()] + + return { + "emails": emails, + "count": len(emails), + } + + +# ============== TLD Management ============== + @router.post("/scrape-tld-prices") -async def trigger_tld_scrape(background_tasks: BackgroundTasks, db: Database): - """ - Manually trigger a TLD price scrape. - - This runs in the background and returns immediately. - Check logs for scrape results. - - NOTE: In production, this should require admin authentication! - """ +async def trigger_tld_scrape( + db: Database, + admin: User = Depends(require_admin), +): + """Manually trigger a TLD price scrape.""" from app.services.tld_scraper.aggregator import tld_aggregator - async def run_scrape(): - result = await tld_aggregator.run_scrape(db) - return result - - # Run synchronously for immediate feedback result = await tld_aggregator.run_scrape(db) return { @@ -43,34 +483,34 @@ async def trigger_tld_scrape(background_tasks: BackgroundTasks, db: Database): @router.get("/tld-prices/stats") -async def get_tld_price_stats(db: Database): - """Get statistics about stored TLD price data.""" - from sqlalchemy import func - from app.models.tld_price import TLDPrice - +async def get_tld_price_stats( + db: Database, + admin: User = Depends(require_admin), +): + """Get TLD price data statistics.""" # Total records - total_result = await db.execute(select(func.count(TLDPrice.id))) - total_records = total_result.scalar() + total = await db.execute(select(func.count(TLDPrice.id))) + total_records = total.scalar() # Unique TLDs - tlds_result = await db.execute(select(func.count(func.distinct(TLDPrice.tld)))) - unique_tlds = tlds_result.scalar() + tlds = await db.execute(select(func.count(func.distinct(TLDPrice.tld)))) + unique_tlds = tlds.scalar() # Unique registrars - registrars_result = await db.execute(select(func.count(func.distinct(TLDPrice.registrar)))) - unique_registrars = registrars_result.scalar() + registrars = await db.execute(select(func.count(func.distinct(TLDPrice.registrar)))) + unique_registrars = registrars.scalar() # Latest record - latest_result = await db.execute( - select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.desc()).limit(1) + latest = await db.execute( + select(TLDPrice.recorded_at).order_by(desc(TLDPrice.recorded_at)).limit(1) ) - latest_record = latest_result.scalar() + latest_record = latest.scalar() # Oldest record - oldest_result = await db.execute( + oldest = await db.execute( select(TLDPrice.recorded_at).order_by(TLDPrice.recorded_at.asc()).limit(1) ) - oldest_record = oldest_result.scalar() + oldest_record = oldest.scalar() return { "total_records": total_records, @@ -82,108 +522,55 @@ async def get_tld_price_stats(db: Database): } -class UpgradeUserRequest(BaseModel): - """Request schema for upgrading a user.""" - email: EmailStr - tier: str # starter, professional, enterprise +# ============== System ============== - -@router.post("/upgrade-user") -async def upgrade_user(request: UpgradeUserRequest, db: Database): - """ - Upgrade a user's subscription tier. - - NOTE: In production, this should require admin authentication! - """ - # Find user - result = await db.execute( - select(User).where(User.email == request.email) - ) - user = result.scalar_one_or_none() - - if not user: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"User with email {request.email} not found" - ) - - # Validate tier +@router.get("/system/health") +async def system_health( + db: Database, + admin: User = Depends(require_admin), +): + """Check system health.""" + # Test database try: - new_tier = SubscriptionTier(request.tier) - except ValueError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid tier: {request.tier}. Valid options: starter, professional, enterprise" - ) + await db.execute(select(1)) + db_status = "healthy" + except Exception as e: + db_status = f"error: {str(e)}" - # Find or create subscription - result = await db.execute( - select(Subscription).where(Subscription.user_id == user.id) - ) - subscription = result.scalar_one_or_none() + # Check email config + from app.services.email_service import EmailService + email_configured = EmailService.is_configured() - if not subscription: - # Create new subscription - subscription = Subscription( - user_id=user.id, - tier=new_tier, - status=SubscriptionStatus.ACTIVE, - domain_limit=TIER_CONFIG[new_tier]["domain_limit"], - ) - db.add(subscription) - else: - # Update existing - subscription.tier = new_tier - subscription.domain_limit = TIER_CONFIG[new_tier]["domain_limit"] - subscription.status = SubscriptionStatus.ACTIVE - - await db.commit() - await db.refresh(subscription) - - config = TIER_CONFIG[new_tier] + # Check Stripe config + import os + stripe_configured = bool(os.getenv("STRIPE_SECRET_KEY")) return { - "message": f"User {request.email} upgraded to {config['name']}", - "user_id": user.id, - "tier": new_tier.value, - "tier_name": config["name"], - "domain_limit": config["domain_limit"], - "features": config["features"], + "status": "healthy" if db_status == "healthy" else "degraded", + "database": db_status, + "email_configured": email_configured, + "stripe_configured": stripe_configured, + "timestamp": datetime.utcnow().isoformat(), } -@router.get("/users") -async def list_users(db: Database, limit: int = 50, offset: int = 0): +@router.post("/system/make-admin") +async def make_user_admin( + email: str, + db: Database, +): """ - List all users with their subscriptions. - - NOTE: In production, this should require admin authentication! + Make a user an admin. + NOTE: This endpoint has NO authentication for initial setup! + In production, disable after first admin is created. """ - result = await db.execute( - select(User).offset(offset).limit(limit) - ) - users = result.scalars().all() + result = await db.execute(select(User).where(User.email == email)) + user = result.scalar_one_or_none() - user_list = [] - for user in users: - # Get subscription - sub_result = await db.execute( - select(Subscription).where(Subscription.user_id == user.id) - ) - subscription = sub_result.scalar_one_or_none() - - user_list.append({ - "id": user.id, - "email": user.email, - "name": user.name, - "is_active": user.is_active, - "created_at": user.created_at.isoformat(), - "subscription": { - "tier": subscription.tier.value if subscription else None, - "status": subscription.status.value if subscription else None, - "domain_limit": subscription.domain_limit if subscription else 0, - } if subscription else None, - }) + if not user: + raise HTTPException(status_code=404, detail="User not found") - return {"users": user_list, "count": len(user_list)} - + user.is_admin = True + await db.commit() + + return {"message": f"User {email} is now an admin"} diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 5f9bfe3..67599fe 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -25,6 +25,7 @@ class User(Base): # Status is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_verified: Mapped[bool] = mapped_column(Boolean, default=False) + is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # Password Reset password_reset_token: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 0000000..c934bab --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,635 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { Header } from '@/components/Header' +import { Footer } from '@/components/Footer' +import { useStore } from '@/lib/store' +import { api } from '@/lib/api' +import { + Users, + Database, + Mail, + TrendingUp, + Shield, + RefreshCw, + Search, + ChevronRight, + Crown, + Zap, + Activity, + Globe, + Bell, + Briefcase, + AlertCircle, + Check, + Loader2, + Trash2, + Edit, + Eye, +} from 'lucide-react' +import clsx from 'clsx' + +type TabType = 'overview' | 'users' | 'newsletter' | 'tld' | 'system' + +interface AdminStats { + users: { + total: number + active: number + verified: number + new_this_week: number + } + subscriptions: Record + domains: { + watched: number + portfolio: number + } + tld_data: { + unique_tlds: number + price_records: number + } + newsletter_subscribers: number + auctions: number + price_alerts: number +} + +interface AdminUser { + id: number + email: string + name: string | null + is_active: boolean + is_verified: boolean + is_admin: boolean + created_at: string + last_login: string | null + domain_count: number + subscription: { + tier: string + tier_name: string + status: string | null + domain_limit: number + } +} + +interface NewsletterSubscriber { + id: number + email: string + is_active: boolean + subscribed_at: string + unsubscribed_at: string | null +} + +export default function AdminPage() { + const router = useRouter() + const { user, isAuthenticated, isLoading, checkAuth } = useStore() + + const [activeTab, setActiveTab] = useState('overview') + const [stats, setStats] = useState(null) + const [users, setUsers] = useState([]) + const [usersTotal, setUsersTotal] = useState(0) + const [newsletter, setNewsletter] = useState([]) + const [newsletterTotal, setNewsletterTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [scraping, setScraping] = useState(false) + + useEffect(() => { + checkAuth() + }, [checkAuth]) + + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.push('/login') + } + }, [isLoading, isAuthenticated, router]) + + useEffect(() => { + if (isAuthenticated) { + loadAdminData() + } + }, [isAuthenticated, activeTab]) + + const loadAdminData = async () => { + setLoading(true) + setError(null) + + try { + if (activeTab === 'overview') { + const statsData = await api.getAdminStats() + setStats(statsData) + } else if (activeTab === 'users') { + const usersData = await api.getAdminUsers(50, 0, searchQuery || undefined) + setUsers(usersData.users) + setUsersTotal(usersData.total) + } else if (activeTab === 'newsletter') { + const nlData = await api.getAdminNewsletter(100, 0) + setNewsletter(nlData.subscribers) + setNewsletterTotal(nlData.total) + } + } catch (err) { + if (err instanceof Error && err.message.includes('403')) { + setError('Admin privileges required. You are not authorized to access this page.') + } else { + setError(err instanceof Error ? err.message : 'Failed to load admin data') + } + } finally { + setLoading(false) + } + } + + const handleTriggerScrape = async () => { + setScraping(true) + setError(null) + + try { + const result = await api.triggerTldScrape() + setSuccess(`Scrape completed: ${result.tlds_scraped} TLDs, ${result.prices_saved} prices saved`) + } catch (err) { + setError(err instanceof Error ? err.message : 'Scrape failed') + } finally { + setScraping(false) + } + } + + const handleUpgradeUser = async (userId: number, tier: string) => { + try { + await api.upgradeUser(userId, tier) + setSuccess(`User upgraded to ${tier}`) + loadAdminData() + } catch (err) { + setError(err instanceof Error ? err.message : 'Upgrade failed') + } + } + + const handleToggleAdmin = async (userId: number, isAdmin: boolean) => { + try { + await api.updateAdminUser(userId, { is_admin: !isAdmin }) + setSuccess(isAdmin ? 'Admin privileges removed' : 'Admin privileges granted') + loadAdminData() + } catch (err) { + setError(err instanceof Error ? err.message : 'Update failed') + } + } + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (error && error.includes('403')) { + return ( +
+
+
+
+ +

Access Denied

+

+ You don't have admin privileges to access this page. +

+ +
+
+
+
+ ) + } + + const tabs = [ + { id: 'overview' as const, label: 'Overview', icon: Activity }, + { id: 'users' as const, label: 'Users', icon: Users }, + { id: 'newsletter' as const, label: 'Newsletter', icon: Mail }, + { id: 'tld' as const, label: 'TLD Data', icon: Globe }, + { id: 'system' as const, label: 'System', icon: Database }, + ] + + return ( +
+
+ +
+
+ {/* Header */} +
+
+ +

+ Admin Dashboard +

+
+

+ Manage users, monitor system health, and control platform settings. +

+
+ + {/* Messages */} + {error && !error.includes('403') && ( +
+ +

{error}

+ +
+ )} + + {success && ( +
+ +

{success}

+ +
+ )} + + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {loading ? ( +
+ +
+ ) : ( + <> + {/* Overview Tab */} + {activeTab === 'overview' && stats && ( +
+ {/* Stats Grid */} +
+ + + + +
+ + {/* Subscription Breakdown */} +
+

Subscriptions by Tier

+
+
+
+ + Scout +
+

{stats.subscriptions.scout || 0}

+
+
+
+ + Trader +
+

{stats.subscriptions.trader || 0}

+
+
+
+ + Tycoon +
+

{stats.subscriptions.tycoon || 0}

+
+
+
+ + {/* Quick Actions */} +
+

Quick Actions

+
+ +
+
+
+ )} + + {/* Users Tab */} + {activeTab === 'users' && ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && loadAdminData()} + placeholder="Search users by email or name..." + className="w-full pl-11 pr-4 py-2.5 bg-background-secondary border border-border rounded-xl text-body-sm text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50" + /> +
+ + {/* Users Table */} +
+
+ + + + + + + + + + + + {users.map((u) => ( + + + + + + + + ))} + +
UserStatusTierDomainsActions
+
+

{u.email}

+

{u.name || 'No name'}

+
+
+
+ {u.is_admin && ( + Admin + )} + {u.is_verified && ( + Verified + )} + {!u.is_active && ( + Inactive + )} +
+
+ + {u.subscription.tier_name} + + + {u.domain_count} + +
+ + +
+
+
+
+ +

+ Showing {users.length} of {usersTotal} users +

+
+ )} + + {/* Newsletter Tab */} + {activeTab === 'newsletter' && ( +
+
+

+ {newsletterTotal} total subscribers +

+ +
+ +
+ + + + + + + + + + {newsletter.map((s) => ( + + + + + + ))} + +
EmailStatusSubscribed
{s.email} + + {s.is_active ? 'Active' : 'Unsubscribed'} + + + {new Date(s.subscribed_at).toLocaleDateString()} +
+
+
+ )} + + {/* TLD Tab */} + {activeTab === 'tld' && ( +
+
+
+

TLD Price Data

+
+
+ Unique TLDs + {stats?.tld_data.unique_tlds || 0} +
+
+ Price Records + {stats?.tld_data.price_records?.toLocaleString() || 0} +
+
+
+ +
+

Scrape TLD Prices

+

+ Manually trigger a TLD price scrape from all sources. +

+ +
+
+
+ )} + + {/* System Tab */} + {activeTab === 'system' && ( +
+
+

System Status

+
+
+ Database + + + Healthy + +
+
+ Email (Zoho SMTP) + + + Configured + +
+
+ Stripe Payments + + + Check .env + +
+
+
+ +
+

Environment

+
+

SMTP_HOST: smtp.zoho.eu

+

SMTP_PORT: 465

+

SMTP_USE_SSL: true

+
+
+
+ )} + + )} +
+
+ +
+
+ ) +} + +// Stat Card Component +function StatCard({ + title, + value, + subtitle, + icon: Icon +}: { + title: string + value: number + subtitle: string + icon: React.ComponentType<{ className?: string }> +}) { + return ( +
+
+ + {title} +
+

{value.toLocaleString()}

+

{subtitle}

+
+ ) +} + diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index ad196ea..6d5a588 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -13,43 +13,46 @@ export function Header() {
{/* Left side: Logo + Nav Links */} -
- {/* Logo - Playfair Display (same as titles) */} +
+ {/* Logo - Playfair Display font */} - + pounce {/* Left Nav Links (Desktop) - vertically centered */} -
{/* Right side: Auth Links - vertically centered */} -