diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 51f5d88..b6f0273 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,14 +1,11 @@ 'use client' import { useEffect, useState } from 'react' -import { useRouter } from 'next/navigation' -import { CommandCenterLayout } from '@/components/CommandCenterLayout' -import { DataTable, StatusBadge, TableAction } from '@/components/DataTable' -import { useStore } from '@/lib/store' +import { AdminLayout } from '@/components/AdminLayout' +import { PremiumTable, Badge, TableActionButton, StatCard, PageContainer } from '@/components/PremiumTable' import { api } from '@/lib/api' import { Users, - Database, Mail, TrendingUp, Shield, @@ -29,14 +26,13 @@ import { XCircle, Download, Send, - Clock, History, X, BookOpen, Plus, Edit2, ExternalLink, - Sparkles, + Database, } from 'lucide-react' import clsx from 'clsx' @@ -66,9 +62,6 @@ interface AdminUser { } export default function AdminPage() { - const router = useRouter() - const { user, isAuthenticated, isLoading } = useStore() - const [activeTab, setActiveTab] = useState('overview') const [stats, setStats] = useState(null) const [users, setUsers] = useState([]) @@ -95,16 +88,8 @@ export default function AdminPage() { const [bulkTier, setBulkTier] = useState('trader') useEffect(() => { - if (!isLoading && !isAuthenticated) { - router.push('/login') - } - }, [isLoading, isAuthenticated, router]) - - useEffect(() => { - if (isAuthenticated) { - loadAdminData() - } - }, [isAuthenticated, activeTab]) + loadAdminData() + }, [activeTab]) const loadAdminData = async () => { setLoading(true) @@ -127,33 +112,23 @@ export default function AdminPage() { setNewsletter(nlData.subscribers) setNewsletterTotal(nlData.total) } else if (activeTab === 'system') { - try { - const [healthData, schedulerData] = await Promise.all([ - api.getSystemHealth(), - api.getSchedulerStatus(), - ]) - setSystemHealth(healthData) - setSchedulerStatus(schedulerData) - } catch { /* ignore */ } + const [healthData, schedulerData] = await Promise.all([ + api.getSystemHealth().catch(() => null), + api.getSchedulerStatus().catch(() => null), + ]) + setSystemHealth(healthData) + setSchedulerStatus(schedulerData) } else if (activeTab === 'activity') { - try { - const logData = await api.getActivityLog(50, 0) - setActivityLog(logData.logs) - setActivityLogTotal(logData.total) - } catch { /* ignore */ } + const logData = await api.getActivityLog(50, 0).catch(() => ({ logs: [], total: 0 })) + setActivityLog(logData.logs) + setActivityLogTotal(logData.total) } else if (activeTab === 'blog') { - try { - const blogData = await api.getAdminBlogPosts(50, 0) - setBlogPosts(blogData.posts) - setBlogPostsTotal(blogData.total) - } catch { /* ignore */ } + const blogData = await api.getAdminBlogPosts(50, 0).catch(() => ({ posts: [], total: 0 })) + setBlogPosts(blogData.posts) + setBlogPostsTotal(blogData.total) } } catch (err) { - if (err instanceof Error && err.message.includes('403')) { - setError('Admin privileges required.') - } else { - setError(err instanceof Error ? err.message : 'Failed to load admin data') - } + setError(err instanceof Error ? err.message : 'Failed to load admin data') } finally { setLoading(false) } @@ -161,10 +136,9 @@ export default function AdminPage() { 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`) + setSuccess(`Scrape completed: ${result.tlds_scraped} TLDs`) } catch (err) { setError(err instanceof Error ? err.message : 'Scrape failed') } finally { @@ -174,10 +148,9 @@ export default function AdminPage() { const handleTriggerAuctionScrape = async () => { setAuctionScraping(true) - setError(null) try { const result = await api.triggerAuctionScrape() - setSuccess(`Auction scrape completed: ${result.total_auctions || 0} auctions found`) + setSuccess(`Auction scrape completed: ${result.total_auctions || 0} auctions`) loadAdminData() } catch (err) { setError(err instanceof Error ? err.message : 'Auction scrape failed') @@ -188,10 +161,9 @@ export default function AdminPage() { const handleTriggerDomainChecks = async () => { setDomainChecking(true) - setError(null) try { const result = await api.triggerDomainChecks() - setSuccess(`Domain checks started: ${result.domains_queued} domains queued`) + setSuccess(`Domain checks started: ${result.domains_queued} queued`) } catch (err) { setError(err instanceof Error ? err.message : 'Domain check failed') } finally { @@ -201,7 +173,6 @@ export default function AdminPage() { const handleSendTestEmail = async () => { setSendingEmail(true) - setError(null) try { const result = await api.sendTestEmail() setSuccess(`Test email sent to ${result.sent_to}`) @@ -225,39 +196,24 @@ export default function AdminPage() { const handleToggleAdmin = async (userId: number, isAdmin: boolean) => { try { await api.updateAdminUser(userId, { is_admin: !isAdmin }) - setSuccess(isAdmin ? 'Admin privileges removed' : 'Admin privileges granted') + setSuccess(isAdmin ? 'Admin removed' : 'Admin granted') loadAdminData() } catch (err) { setError(err instanceof Error ? err.message : 'Update failed') } } - const handleDeleteUser = async (userId: number, userEmail: string) => { - if (!confirm(`Delete user "${userEmail}" and ALL their data?\n\nThis cannot be undone.`)) return + const handleDeleteUser = async (userId: number, email: string) => { + if (!confirm(`Delete user "${email}"? This cannot be undone.`)) return try { await api.deleteAdminUser(userId) - setSuccess(`User ${userEmail} deleted`) + setSuccess(`User ${email} deleted`) loadAdminData() } catch (err) { setError(err instanceof Error ? err.message : 'Delete failed') } } - const handleBulkUpgrade = async () => { - if (selectedUsers.length === 0) { - setError('Please select users to upgrade') - return - } - try { - const result = await api.bulkUpgradeUsers(selectedUsers, bulkTier) - setSuccess(`Upgraded ${result.total_upgraded} users to ${bulkTier}`) - setSelectedUsers([]) - loadAdminData() - } catch (err) { - setError(err instanceof Error ? err.message : 'Bulk upgrade failed') - } - } - const handleExportUsers = async () => { try { const result = await api.exportUsersCSV() @@ -265,7 +221,7 @@ export default function AdminPage() { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url - a.download = `users-export-${new Date().toISOString().split('T')[0]}.csv` + a.download = `users-${new Date().toISOString().split('T')[0]}.csv` a.click() setSuccess(`Exported ${result.count} users`) } catch (err) { @@ -273,491 +229,334 @@ export default function AdminPage() { } } - if (isLoading) { - return ( -
-
-
- ) - } - - if (error && error.includes('403')) { - return ( - -
- -

Admin Access Required

-

You don't have admin privileges.

- -
-
- ) - } - - const tabs = [ - { id: 'overview' as const, label: 'Overview', icon: Activity }, - { id: 'users' as const, label: 'Users', icon: Users }, - { id: 'alerts' as const, label: 'Alerts', icon: Bell }, - { id: 'newsletter' as const, label: 'Newsletter', icon: Mail }, - { id: 'tld' as const, label: 'TLD Data', icon: Globe }, - { id: 'auctions' as const, label: 'Auctions', icon: Gavel }, - { id: 'blog' as const, label: 'Blog', icon: BookOpen }, - { id: 'system' as const, label: 'System', icon: Database }, - { id: 'activity' as const, label: 'Activity', icon: History }, - ] - return ( - setActiveTab(tab as TabType)} > - {/* Messages */} - {error && !error.includes('403') && ( -
- -

{error}

- -
- )} + + {/* Messages */} + {error && ( +
+ +

{error}

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

{success}

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

{success}

+ +
+ )} - {/* Tabs */} -
- {tabs.map((tab) => ( - - ))} -
- - {loading ? ( -
- -
- ) : ( - <> - {/* Overview Tab */} - {activeTab === 'overview' && stats && ( -
-
- - - - -
- -
- {['scout', 'trader', 'tycoon'].map((tier) => ( -
-
- {tier === 'tycoon' ? : - tier === 'trader' ? : - } - {tier} -
-

{stats.subscriptions[tier] || 0}

-
- ))} -
- -
-
-

Active Auctions

-

{stats.auctions.toLocaleString()}

-

from all platforms

+ {loading ? ( +
+ +
+ ) : ( + <> + {/* Overview Tab */} + {activeTab === 'overview' && stats && ( +
+
+ + + +
-
-

Price Alerts

-

{stats.price_alerts.toLocaleString()}

-

active alerts

-
-
-
- )} - {/* Users Tab */} - {activeTab === 'users' && ( -
-
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && loadAdminData()} - placeholder="Search users..." - className="w-full pl-11 pr-4 py-3 bg-background-secondary/50 border border-border/30 rounded-xl text-sm text-foreground placeholder:text-foreground-subtle focus:outline-none focus:border-accent/50" - /> -
- -
- - {selectedUsers.length > 0 && ( -
- {selectedUsers.length} users selected -
- - - -
-
- )} - - u.id} - selectable - selectedIds={selectedUsers} - onSelectionChange={(ids) => setSelectedUsers(ids as number[])} - columns={[ - { - key: 'email', - header: 'User', - render: (u) => ( -
-

{u.email}

-

{u.name || 'No name'}

-
- ), - }, - { - key: 'status', - header: 'Status', - hideOnMobile: true, - render: (u) => ( -
- {u.is_admin && } - {u.is_verified && } - {!u.is_active && } -
- ), - }, - { - key: 'tier', - header: 'Tier', - render: (u) => ( - - ), - }, - { - key: 'domains', - header: 'Domains', - hideOnMobile: true, - render: (u) => {u.domain_count}, - }, - { - key: 'actions', - header: 'Actions', - className: 'text-right', - headerClassName: 'text-right', - render: (u) => ( -
- - handleToggleAdmin(u.id, u.is_admin)} - variant={u.is_admin ? 'accent' : 'default'} - title={u.is_admin ? 'Remove admin' : 'Make admin'} - /> - handleDeleteUser(u.id, u.email)} - variant="danger" - disabled={u.is_admin} - title={u.is_admin ? 'Cannot delete admin' : 'Delete user'} - /> -
- ), - }, - ]} - emptyState={ -
- -

No users found

-
- } - /> -

Showing {users.length} of {usersTotal} users

-
- )} - - {/* Price Alerts Tab */} - {activeTab === 'alerts' && ( -
-

{priceAlertsTotal} active price alerts

- a.id} - columns={[ - { key: 'tld', header: 'TLD', render: (a) => .{a.tld} }, - { key: 'target_price', header: 'Target', render: (a) => a.target_price ? `$${a.target_price.toFixed(2)}` : '—' }, - { key: 'alert_type', header: 'Type', render: (a) => }, - { key: 'user', header: 'User', render: (a) => a.user.email }, - { key: 'created_at', header: 'Created', hideOnMobile: true, render: (a) => new Date(a.created_at).toLocaleDateString() }, - ]} - emptyState={<>

No active alerts

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

{newsletterTotal} subscribers

- -
- s.id} - columns={[ - { key: 'email', header: 'Email', render: (s) => {s.email} }, - { key: 'is_active', header: 'Status', render: (s) => }, - { key: 'subscribed_at', header: 'Subscribed', render: (s) => 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.

- -
-
- )} - - {/* Auctions Tab */} - {activeTab === 'auctions' && ( -
-
-

Auction Data

-

{stats?.auctions || 0}

-

Total auctions

-
-
-

Scrape Auctions

-

GoDaddy, Sedo, NameJet, DropCatch

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

System Status

-
+
{[ - { label: 'Database', ok: systemHealth?.database === 'healthy', text: systemHealth?.database || 'Unknown' }, - { label: 'Email (SMTP)', ok: systemHealth?.email_configured, text: systemHealth?.email_configured ? 'Configured' : 'Not configured' }, - { label: 'Stripe', ok: systemHealth?.stripe_configured, text: systemHealth?.stripe_configured ? 'Configured' : 'Not configured' }, - { label: 'Scheduler', ok: schedulerStatus?.scheduler_running, text: schedulerStatus?.scheduler_running ? 'Running' : 'Stopped' }, - ].map((item) => ( -
- {item.label} - - {item.ok ? : } - {item.text} - + { tier: 'scout', icon: Zap, color: 'text-foreground-muted' }, + { tier: 'trader', icon: TrendingUp, color: 'text-accent' }, + { tier: 'tycoon', icon: Crown, color: 'text-amber-400' }, + ].map(({ tier, icon: Icon, color }) => ( +
+
+ + {tier} +
+

{stats.subscriptions[tier] || 0}

))}
-
-
+
+
+

Active Auctions

+

{stats.auctions.toLocaleString()}

+
+
+

Price Alerts

+

{stats.price_alerts.toLocaleString()}

+
+
+
+ )} + + {/* Users Tab */} + {activeTab === 'users' && ( +
+
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && loadAdminData()} + placeholder="Search users..." + className="w-full pl-11 pr-4 py-3 bg-background-secondary/50 border border-border/30 rounded-xl text-sm" + /> +
+ +
+ + u.id} + selectable + selectedIds={selectedUsers} + onSelectionChange={(ids) => setSelectedUsers(ids as number[])} + columns={[ + { + key: 'user', + header: 'User', + render: (u) => ( +
+

{u.email}

+

{u.name || 'No name'}

+
+ ), + }, + { + key: 'status', + header: 'Status', + hideOnMobile: true, + render: (u) => ( +
+ {u.is_admin && Admin} + {u.is_verified && Verified} + {!u.is_active && Inactive} +
+ ), + }, + { + key: 'tier', + header: 'Tier', + render: (u) => ( + + {u.subscription.tier_name} + + ), + }, + { + key: 'domains', + header: 'Domains', + hideOnMobile: true, + render: (u) => {u.domain_count}, + }, + { + key: 'actions', + header: 'Actions', + align: 'right', + render: (u) => ( +
+ + handleToggleAdmin(u.id, u.is_admin)} variant={u.is_admin ? 'accent' : 'default'} title={u.is_admin ? 'Remove admin' : 'Make admin'} /> + handleDeleteUser(u.id, u.email)} variant="danger" disabled={u.is_admin} title="Delete user" /> +
+ ), + }, + ]} + emptyIcon={} + emptyTitle="No users found" + /> +

Showing {users.length} of {usersTotal} users

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

{newsletterTotal} subscribers

+ +
+ s.id} + columns={[ + { key: 'email', header: 'Email', render: (s) => {s.email} }, + { key: 'status', header: 'Status', render: (s) => {s.is_active ? 'Active' : 'Unsubscribed'} }, + { key: 'subscribed', header: 'Subscribed', render: (s) => new Date(s.subscribed_at).toLocaleDateString() }, + ]} + /> +
+ )} + + {/* System Tab */} + {activeTab === 'system' && ( +
-

Manual Triggers

-
- - +

System Status

+
+ {[ + { label: 'Database', ok: systemHealth?.database === 'healthy', text: systemHealth?.database || 'Unknown' }, + { label: 'Email (SMTP)', ok: systemHealth?.email_configured, text: systemHealth?.email_configured ? 'Configured' : 'Not configured' }, + { label: 'Stripe', ok: systemHealth?.stripe_configured, text: systemHealth?.stripe_configured ? 'Configured' : 'Not configured' }, + { label: 'Scheduler', ok: schedulerStatus?.scheduler_running, text: schedulerStatus?.scheduler_running ? 'Running' : 'Stopped' }, + ].map((item) => ( +
+ {item.label} + + {item.ok ? : } + {item.text} + +
+ ))}
- {schedulerStatus && ( +
-

Scheduled Jobs

+

Manual Triggers

- {schedulerStatus.jobs?.slice(0, 4).map((job: any) => ( -
- {job.name} - {job.trigger} -
- ))} + + + +
- )} -
-
- )} - {/* Blog Tab */} - {activeTab === 'blog' && ( -
-
-

{blogPostsTotal} blog posts

- -
- p.id} - columns={[ - { key: 'title', header: 'Title', render: (p) =>

{p.title}

{p.slug}

}, - { key: 'category', header: 'Category', hideOnMobile: true, render: (p) => p.category ? : '—' }, - { key: 'is_published', header: 'Status', render: (p) => }, - { key: 'view_count', header: 'Views', hideOnMobile: true, render: (p) => p.view_count }, - { key: 'actions', header: 'Actions', className: 'text-right', headerClassName: 'text-right', render: (p) => ( -
- window.open(`/blog/${p.slug}`, '_blank')} title="View" /> - - + {schedulerStatus && ( +
+

Scheduled Jobs

+
+ {schedulerStatus.jobs?.slice(0, 5).map((job: any) => ( +
+ {job.name} + {job.trigger} +
+ ))} +
- ) }, - ]} - emptyState={<>

No blog posts yet

} - /> -
- )} + )} +
+
+ )} - {/* Activity Tab */} - {activeTab === 'activity' && ( -
-

{activityLogTotal} log entries

- a.id} + emptyIcon={} + emptyTitle="No active price alerts" + columns={[ + { key: 'tld', header: 'TLD', render: (a) => .{a.tld} }, + { key: 'target', header: 'Target', render: (a) => a.target_price ? `$${a.target_price.toFixed(2)}` : '—' }, + { key: 'type', header: 'Type', render: (a) => {a.alert_type} }, + { key: 'user', header: 'User', render: (a) => a.user.email }, + { key: 'created', header: 'Created', hideOnMobile: true, render: (a) => new Date(a.created_at).toLocaleDateString() }, + ]} + /> + )} + + {activeTab === 'activity' && ( + l.id} + emptyIcon={} + emptyTitle="No activity logged" columns={[ - { key: 'action', header: 'Action', render: (l) => }, + { key: 'action', header: 'Action', render: (l) => {l.action} }, { key: 'details', header: 'Details', render: (l) => {l.details} }, { key: 'admin', header: 'Admin', render: (l) => l.admin.email }, - { key: 'created_at', header: 'Time', hideOnMobile: true, render: (l) => new Date(l.created_at).toLocaleString() }, + { key: 'time', header: 'Time', hideOnMobile: true, render: (l) => new Date(l.created_at).toLocaleString() }, ]} - emptyState={<>

No activity logged

} /> -
- )} - - )} - - ) -} + )} -// 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}

-
-
+ {activeTab === 'blog' && ( +
+
+

{blogPostsTotal} posts

+ +
+ p.id} + emptyIcon={} + emptyTitle="No blog posts" + columns={[ + { key: 'title', header: 'Title', render: (p) =>

{p.title}

{p.slug}

}, + { key: 'category', header: 'Category', hideOnMobile: true, render: (p) => p.category ? {p.category} : '—' }, + { key: 'status', header: 'Status', render: (p) => {p.is_published ? 'Published' : 'Draft'} }, + { key: 'views', header: 'Views', hideOnMobile: true, render: (p) => p.view_count }, + { key: 'actions', header: '', align: 'right', render: (p) => ( +
+ window.open(`/blog/${p.slug}`, '_blank')} /> + + +
+ ) }, + ]} + /> +
+ )} + + )} + + ) } diff --git a/frontend/src/app/auctions/layout.tsx b/frontend/src/app/auctions/layout.tsx deleted file mode 100644 index 7438375..0000000 --- a/frontend/src/app/auctions/layout.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { Metadata } from 'next' - -const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://pounce.ch' - -export const metadata: Metadata = { - title: 'Domain Auctions — Smart Pounce', - description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet, SnapNames & DropCatch. Our Smart Pounce algorithm identifies the best opportunities with transparent valuations.', - keywords: [ - 'domain auctions', - 'expired domains', - 'domain bidding', - 'GoDaddy auctions', - 'Sedo domains', - 'NameJet', - 'domain investment', - 'undervalued domains', - 'domain flipping', - ], - openGraph: { - title: 'Domain Auctions — Smart Pounce by pounce', - description: 'Find undervalued domain auctions. Transparent valuations, multiple platforms, no payment handling.', - url: `${siteUrl}/auctions`, - type: 'website', - images: [ - { - url: `${siteUrl}/og-auctions.png`, - width: 1200, - height: 630, - alt: 'Smart Pounce - Domain Auction Aggregator', - }, - ], - }, - twitter: { - card: 'summary_large_image', - title: 'Domain Auctions — Smart Pounce', - description: 'Find undervalued domain auctions from GoDaddy, Sedo, NameJet & more.', - }, - alternates: { - canonical: `${siteUrl}/auctions`, - }, -} - -// JSON-LD for Auctions page -const jsonLd = { - '@context': 'https://schema.org', - '@type': 'WebPage', - name: 'Domain Auctions — Smart Pounce', - description: 'Aggregated domain auctions from multiple platforms with transparent algorithmic valuations.', - url: `${siteUrl}/auctions`, - isPartOf: { - '@type': 'WebSite', - name: 'pounce', - url: siteUrl, - }, - about: { - '@type': 'Service', - name: 'Smart Pounce', - description: 'Domain auction aggregation and opportunity analysis', - provider: { - '@type': 'Organization', - name: 'pounce', - }, - }, - mainEntity: { - '@type': 'ItemList', - name: 'Domain Auctions', - description: 'Live domain auctions from GoDaddy, Sedo, NameJet, SnapNames, and DropCatch', - itemListElement: [ - { - '@type': 'ListItem', - position: 1, - name: 'GoDaddy Auctions', - url: 'https://auctions.godaddy.com', - }, - { - '@type': 'ListItem', - position: 2, - name: 'Sedo', - url: 'https://sedo.com', - }, - { - '@type': 'ListItem', - position: 3, - name: 'NameJet', - url: 'https://namejet.com', - }, - ], - }, -} - -export default function AuctionsLayout({ - children, -}: { - children: React.ReactNode -}) { - return ( - <> -