diff --git a/backend/app/services/seo_analyzer.py b/backend/app/services/seo_analyzer.py index fb90e66..158e98b 100644 --- a/backend/app/services/seo_analyzer.py +++ b/backend/app/services/seo_analyzer.py @@ -81,22 +81,37 @@ class SEOAnalyzerService: """ domain = domain.lower().strip() - # Check cache first - if not force_refresh: - cached = await self._get_cached(domain, db) - if cached and not cached.is_expired: + try: + # Check cache first + if not force_refresh: + try: + cached = await self._get_cached(domain, db) + if cached and not cached.is_expired: + return self._format_response(cached) + except Exception as e: + # Table might not exist yet + logger.warning(f"Cache check failed (table may not exist): {e}") + + # Fetch fresh data + if self.has_moz: + seo_data = await self._fetch_moz_data(domain) + else: + seo_data = await self._estimate_seo_data(domain) + + # Try to save to cache (may fail if table doesn't exist) + try: + cached = await self._save_to_cache(domain, seo_data, db) return self._format_response(cached) - - # Fetch fresh data - if self.has_moz: - seo_data = await self._fetch_moz_data(domain) - else: + except Exception as e: + logger.warning(f"Cache save failed (table may not exist): {e}") + # Return data directly without caching + return self._format_dict_response(domain, seo_data) + + except Exception as e: + logger.error(f"SEO analysis failed for {domain}: {e}") + # Return estimated data on any error seo_data = await self._estimate_seo_data(domain) - - # Save to cache - cached = await self._save_to_cache(domain, seo_data, db) - - return self._format_response(cached) + return self._format_dict_response(domain, seo_data) async def _get_cached(self, domain: str, db: AsyncSession) -> Optional[DomainSEOData]: """Get cached SEO data for a domain.""" @@ -374,6 +389,56 @@ class SEOAnalyzerService: 'last_updated': data.last_updated.isoformat() if data.last_updated else None, 'is_estimated': data.data_source == 'estimated', } + + def _format_dict_response(self, domain: str, data: Dict[str, Any]) -> Dict[str, Any]: + """Format SEO data from dict (when DB is not available).""" + da = data.get('domain_authority', 0) or 0 + + # Calculate SEO score + seo_score = da + if data.get('has_wikipedia_link'): + seo_score = min(100, seo_score + 10) + if data.get('has_gov_link'): + seo_score = min(100, seo_score + 5) + if data.get('has_edu_link'): + seo_score = min(100, seo_score + 5) + if data.get('has_news_link'): + seo_score = min(100, seo_score + 3) + + # Determine value category + if seo_score >= 60: + value_category = "High Value" + elif seo_score >= 40: + value_category = "Medium Value" + elif seo_score >= 20: + value_category = "Low Value" + else: + value_category = "Minimal" + + return { + 'domain': domain, + 'seo_score': seo_score, + 'value_category': value_category, + 'metrics': { + 'domain_authority': data.get('domain_authority'), + 'page_authority': data.get('page_authority'), + 'spam_score': data.get('spam_score'), + 'total_backlinks': data.get('total_backlinks'), + 'referring_domains': data.get('referring_domains'), + }, + 'notable_links': { + 'has_wikipedia': data.get('has_wikipedia_link', False), + 'has_gov': data.get('has_gov_link', False), + 'has_edu': data.get('has_edu_link', False), + 'has_news': data.get('has_news_link', False), + 'notable_domains': data.get('notable_backlinks', '').split(',') if data.get('notable_backlinks') else [], + }, + 'top_backlinks': data.get('top_backlinks', []), + 'estimated_value': data.get('seo_value_estimate'), + 'data_source': data.get('data_source', 'estimated'), + 'last_updated': datetime.utcnow().isoformat(), + 'is_estimated': data.get('data_source') == 'estimated', + } # Singleton instance diff --git a/frontend/src/app/command/listings/page.tsx b/frontend/src/app/command/listings/page.tsx index 034363a..ce2174a 100644 --- a/frontend/src/app/command/listings/page.tsx +++ b/frontend/src/app/command/listings/page.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { CommandCenterLayout } from '@/components/CommandCenterLayout' -import { PageContainer, StatCard, PremiumTable, Badge } from '@/components/PremiumTable' +import { PageContainer, StatCard, Badge } from '@/components/PremiumTable' import { Plus, Shield, @@ -13,9 +13,7 @@ import { ExternalLink, Loader2, Trash2, - Edit2, CheckCircle, - Clock, AlertCircle, Copy, RefreshCw, @@ -23,6 +21,10 @@ import { Globe, X, Sparkles, + Store, + List, + Search, + Tag, } from 'lucide-react' import Link from 'next/link' import clsx from 'clsx' @@ -51,6 +53,22 @@ interface Listing { published_at: string | null } +interface PublicListing { + domain: string + slug: string + title: string | null + description: string | null + asking_price: number | null + currency: string + price_type: string + pounce_score: number | null + estimated_value: number | null + is_verified: boolean + allow_offers: boolean + public_url: string + seller_verified: boolean +} + interface VerificationInfo { verification_code: string dns_record_type: string @@ -60,11 +78,24 @@ interface VerificationInfo { status: string } +type TabType = 'browse' | 'my-listings' + export default function ListingsPage() { const { subscription } = useStore() - const [listings, setListings] = useState([]) - const [loading, setLoading] = useState(true) + // Tab state + const [activeTab, setActiveTab] = useState('browse') + + // My Listings state + const [myListings, setMyListings] = useState([]) + const [loadingMy, setLoadingMy] = useState(true) + + // Browse state + const [publicListings, setPublicListings] = useState([]) + const [loadingPublic, setLoadingPublic] = useState(true) + const [searchQuery, setSearchQuery] = useState('') + + // Modals const [showCreateModal, setShowCreateModal] = useState(false) const [showVerifyModal, setShowVerifyModal] = useState(false) const [selectedListing, setSelectedListing] = useState(null) @@ -85,18 +116,31 @@ export default function ListingsPage() { }) useEffect(() => { - loadListings() + loadMyListings() + loadPublicListings() }, []) - const loadListings = async () => { - setLoading(true) + const loadMyListings = async () => { + setLoadingMy(true) try { const data = await api.request('/listings/my') - setListings(data) + setMyListings(data) } catch (err: any) { - setError(err.message) + console.error('Failed to load my listings:', err) } finally { - setLoading(false) + setLoadingMy(false) + } + } + + const loadPublicListings = async () => { + setLoadingPublic(true) + try { + const data = await api.request('/listings?limit=50') + setPublicListings(data) + } catch (err: any) { + console.error('Failed to load public listings:', err) + } finally { + setLoadingPublic(false) } } @@ -120,7 +164,8 @@ export default function ListingsPage() { setSuccess('Listing created! Now verify ownership to publish.') setShowCreateModal(false) setNewListing({ domain: '', title: '', description: '', asking_price: '', price_type: 'negotiable', allow_offers: true }) - loadListings() + loadMyListings() + setActiveTab('my-listings') } catch (err: any) { setError(err.message) } finally { @@ -157,7 +202,7 @@ export default function ListingsPage() { if (result.verified) { setSuccess('Domain verified! You can now publish your listing.') setShowVerifyModal(false) - loadListings() + loadMyListings() } else { setError(result.message) } @@ -175,7 +220,8 @@ export default function ListingsPage() { body: JSON.stringify({ status: 'active' }), }) setSuccess('Listing published!') - loadListings() + loadMyListings() + loadPublicListings() } catch (err: any) { setError(err.message) } @@ -187,7 +233,7 @@ export default function ListingsPage() { try { await api.request(`/listings/${listing.id}`, { method: 'DELETE' }) setSuccess('Listing deleted') - loadListings() + loadMyListings() } catch (err: any) { setError(err.message) } @@ -215,23 +261,33 @@ export default function ListingsPage() { return {status} } + const filteredPublicListings = publicListings.filter(listing => { + if (!searchQuery) return true + return listing.domain.toLowerCase().includes(searchQuery.toLowerCase()) + }) + const tier = subscription?.tier || 'scout' const limits = { scout: 2, trader: 10, tycoon: 50 } const maxListings = limits[tier as keyof typeof limits] || 2 + const tabs = [ + { id: 'browse' as TabType, label: 'Browse Marketplace', icon: Store, count: publicListings.length }, + { id: 'my-listings' as TabType, label: 'My Listings', icon: List, count: myListings.length }, + ] + return ( setShowCreateModal(true)} - disabled={listings.length >= maxListings} + disabled={myListings.length >= maxListings} className="flex items-center gap-2 px-4 py-2 bg-accent text-background text-sm font-medium rounded-lg hover:bg-accent-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed" > - New Listing + List Domain } > @@ -253,135 +309,252 @@ export default function ListingsPage() { )} - {/* Stats */} -
- - l.status === 'active').length} - icon={CheckCircle} - accent - /> - sum + l.view_count, 0)} - icon={Eye} - /> - sum + l.inquiry_count, 0)} - icon={MessageSquare} - /> + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))}
- {/* Listings Table */} - {loading ? ( -
- -
- ) : listings.length === 0 ? ( -
- -

No Listings Yet

-

- Create your first listing to sell a domain on the Pounce marketplace. -

- -
- ) : ( -
- {listings.map((listing) => ( -
-
- {/* Domain Info */} -
-
-

{listing.domain}

- {getStatusBadge(listing.status, listing.is_verified)} + {/* Browse Tab */} + {activeTab === 'browse' && ( +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-12 pr-4 py-3 bg-background-secondary/50 border border-border rounded-xl + text-foreground placeholder:text-foreground-subtle + focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent" + /> +
+ + {/* Listings Grid */} + {loadingPublic ? ( +
+ +
+ ) : filteredPublicListings.length === 0 ? ( +
+ +

No Domains Listed

+

+ {searchQuery ? `No domains match "${searchQuery}"` : 'Be the first to list your domain!'} +

+ +
+ ) : ( +
+ {filteredPublicListings.map((listing) => ( + +
+
+

+ {listing.domain} +

+ {listing.title && ( +

{listing.title}

+ )} +
{listing.is_verified && ( -
- +
+
)}
- {listing.title && ( -

{listing.title}

- )} -
- - {/* Price */} -
-

- {formatPrice(listing.asking_price, listing.currency)} -

- {listing.pounce_score && ( -

Score: {listing.pounce_score}

- )} -
- - {/* Stats */} -
- - {listing.view_count} - - - {listing.inquiry_count} - -
- - {/* Actions */} -
- {!listing.is_verified && ( - - )} - {listing.is_verified && listing.status === 'draft' && ( - - )} - - {listing.status === 'active' && ( - - - View - - )} - - -
-
+
+ {listing.pounce_score && ( +
+ Score: {listing.pounce_score} +
+ )} +
+

+ {formatPrice(listing.asking_price, listing.currency)} +

+ {listing.price_type === 'negotiable' && ( +

Negotiable

+ )} +
+
+ + ))}
- ))} + )} +
+ )} + + {/* My Listings Tab */} + {activeTab === 'my-listings' && ( +
+ {/* Stats */} +
+ + l.status === 'active').length} + icon={CheckCircle} + accent + /> + sum + l.view_count, 0)} + icon={Eye} + /> + sum + l.inquiry_count, 0)} + icon={MessageSquare} + /> +
+ + {/* My Listings */} + {loadingMy ? ( +
+ +
+ ) : myListings.length === 0 ? ( +
+ +

No Listings Yet

+

+ Create your first listing to sell a domain on the Pounce marketplace. +

+ +
+ ) : ( +
+ {myListings.map((listing) => ( +
+
+ {/* Domain Info */} +
+
+

{listing.domain}

+ {getStatusBadge(listing.status, listing.is_verified)} + {listing.is_verified && ( +
+ +
+ )} +
+ {listing.title && ( +

{listing.title}

+ )} +
+ + {/* Price */} +
+

+ {formatPrice(listing.asking_price, listing.currency)} +

+ {listing.pounce_score && ( +

Score: {listing.pounce_score}

+ )} +
+ + {/* Stats */} +
+ + {listing.view_count} + + + {listing.inquiry_count} + +
+ + {/* Actions */} +
+ {!listing.is_verified && ( + + )} + + {listing.is_verified && listing.status === 'draft' && ( + + )} + + {listing.status === 'active' && ( + + + View + + )} + + +
+
+
+ ))} +
+ )}
)} @@ -390,7 +563,7 @@ export default function ListingsPage() { {showCreateModal && (
-

Create Listing

+

List Domain for Sale

@@ -558,4 +731,3 @@ export default function ListingsPage() { ) } - diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 2fd698a..92192fe 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -10,6 +10,7 @@ import { Gavel, CreditCard, LayoutDashboard, + Tag, } from 'lucide-react' import { useState, useEffect } from 'react' import clsx from 'clsx' @@ -39,6 +40,7 @@ export function Header() { // Public navigation - same for all visitors const publicNavItems = [ { href: '/auctions', label: 'Auctions', icon: Gavel }, + { href: '/buy', label: 'Marketplace', icon: Tag }, { href: '/tld-pricing', label: 'TLD Intel', icon: TrendingUp }, { href: '/pricing', label: 'Pricing', icon: CreditCard }, ] diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx old mode 100644 new mode 100755 index 756714b..0fdf93a --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -106,7 +106,7 @@ export function Sidebar({ collapsed: controlledCollapsed, onCollapsedChange }: S }, { href: '/command/listings', - label: 'For Sale', + label: 'Marketplace', icon: Tag, badge: null, },