From ab27cb12952c9b62f1442c69dd20265b6bd95129 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Wed, 17 Dec 2025 08:46:45 +0100 Subject: [PATCH] Inbox: Unread badges, Seller Inbox, Polling updates --- backend/app/api/listings.py | 165 ++++++++ frontend/src/app/terminal/inbox/page.tsx | 502 ++++++++++++++++------- frontend/src/components/Sidebar.tsx | 34 +- frontend/src/lib/api.ts | 37 ++ 4 files changed, 592 insertions(+), 146 deletions(-) diff --git a/backend/app/api/listings.py b/backend/app/api/listings.py index c2e3d18..49c43ec 100644 --- a/backend/app/api/listings.py +++ b/backend/app/api/listings.py @@ -1474,3 +1474,168 @@ async def check_dns_verification( "message": "DNS check failed. Please try again in a few minutes.", } + +# ============== Inbox API (Unified Buyer + Seller) ============== + +@router.get("/inbox/counts") +async def get_inbox_counts( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Get unread message/inquiry counts for both Buyer and Seller roles. + Used for badge display in navigation. + """ + # BUYER: Count inquiries where there are unread seller messages + # A message is "unread" for buyer if sender_user_id != buyer_user_id and no newer message from buyer + buyer_inquiries = await db.execute( + select(ListingInquiry).where(ListingInquiry.buyer_user_id == current_user.id) + ) + buyer_inqs = list(buyer_inquiries.scalars().all()) + + buyer_unread = 0 + for inq in buyer_inqs: + # Get the latest message + latest_msg = await db.execute( + select(ListingInquiryMessage) + .where(ListingInquiryMessage.inquiry_id == inq.id) + .order_by(ListingInquiryMessage.created_at.desc()) + .limit(1) + ) + msg = latest_msg.scalar_one_or_none() + # If latest message is from seller (not buyer), it's unread + if msg and msg.sender_user_id != current_user.id: + buyer_unread += 1 + + # SELLER: Count new/unread inquiries across all listings + seller_listings = await db.execute( + select(DomainListing.id).where(DomainListing.user_id == current_user.id) + ) + listing_ids = [lid for (lid,) in seller_listings.fetchall()] + + seller_unread = 0 + if listing_ids: + # Count inquiries that are 'new' (never read) + new_count = await db.execute( + select(func.count(ListingInquiry.id)).where( + and_( + ListingInquiry.listing_id.in_(listing_ids), + ListingInquiry.status == "new", + ) + ) + ) + seller_unread = new_count.scalar() or 0 + + # Also count inquiries where latest message is from buyer (unread reply) + for lid in listing_ids: + inqs_result = await db.execute( + select(ListingInquiry).where( + and_( + ListingInquiry.listing_id == lid, + ListingInquiry.status.notin_(["closed", "spam"]), + ) + ) + ) + for inq in inqs_result.scalars().all(): + if inq.status == "new": + continue # Already counted + latest_msg = await db.execute( + select(ListingInquiryMessage) + .where(ListingInquiryMessage.inquiry_id == inq.id) + .order_by(ListingInquiryMessage.created_at.desc()) + .limit(1) + ) + msg = latest_msg.scalar_one_or_none() + # If latest message is from buyer (not seller), it's unread for seller + if msg and msg.sender_user_id != current_user.id: + seller_unread += 1 + + return { + "buyer_unread": buyer_unread, + "seller_unread": seller_unread, + "total_unread": buyer_unread + seller_unread, + } + + +@router.get("/inbox/seller") +async def get_seller_inbox( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + status_filter: Optional[str] = Query(None, enum=["all", "new", "read", "replied", "closed", "spam"]), +): + """ + Seller Inbox: Get all inquiries across all listings owned by the current user. + This provides a unified inbox view for sellers. + """ + # Get all listings owned by user + listings_result = await db.execute( + select(DomainListing).where(DomainListing.user_id == current_user.id) + ) + listings = {l.id: l for l in listings_result.scalars().all()} + + if not listings: + return {"inquiries": [], "total": 0, "unread": 0} + + # Build query for inquiries + query = ( + select(ListingInquiry) + .where(ListingInquiry.listing_id.in_(listings.keys())) + .order_by(ListingInquiry.created_at.desc()) + ) + + if status_filter and status_filter != "all": + query = query.where(ListingInquiry.status == status_filter) + + result = await db.execute(query) + inquiries = list(result.scalars().all()) + + # Count unread + unread_count = sum(1 for inq in inquiries if inq.status == "new" or not inq.read_at) + + # Build response with listing info + response_items = [] + for inq in inquiries: + listing = listings.get(inq.listing_id) + if not listing: + continue + + # Get latest message for preview + latest_msg_result = await db.execute( + select(ListingInquiryMessage) + .where(ListingInquiryMessage.inquiry_id == inq.id) + .order_by(ListingInquiryMessage.created_at.desc()) + .limit(1) + ) + latest_msg = latest_msg_result.scalar_one_or_none() + + # Check if has unread reply from buyer + has_unread_reply = False + if latest_msg and latest_msg.sender_user_id != current_user.id and inq.status not in ["closed", "spam"]: + has_unread_reply = True + + response_items.append({ + "id": inq.id, + "listing_id": listing.id, + "domain": listing.domain, + "slug": listing.slug, + "buyer_name": inq.name, + "buyer_email": inq.email, + "offer_amount": inq.offer_amount, + "status": inq.status, + "created_at": inq.created_at.isoformat(), + "read_at": inq.read_at.isoformat() if inq.read_at else None, + "replied_at": getattr(inq, "replied_at", None), + "closed_at": inq.closed_at.isoformat() if getattr(inq, "closed_at", None) else None, + "closed_reason": getattr(inq, "closed_reason", None), + "has_unread_reply": has_unread_reply, + "last_message_preview": (latest_msg.body[:100] + "..." if len(latest_msg.body) > 100 else latest_msg.body) if latest_msg else inq.message[:100], + "last_message_at": latest_msg.created_at.isoformat() if latest_msg else inq.created_at.isoformat(), + "last_message_is_buyer": latest_msg.sender_user_id != current_user.id if latest_msg else True, + }) + + return { + "inquiries": response_items, + "total": len(response_items), + "unread": unread_count, + } + diff --git a/frontend/src/app/terminal/inbox/page.tsx b/frontend/src/app/terminal/inbox/page.tsx index 50354a8..1af3b1d 100644 --- a/frontend/src/app/terminal/inbox/page.tsx +++ b/frontend/src/app/terminal/inbox/page.tsx @@ -20,12 +20,20 @@ import { LogOut, Crown, Zap, + Tag, + ShoppingCart, + DollarSign, + CheckCircle, + AlertCircle, + Mail, + ExternalLink, } from 'lucide-react' import Link from 'next/link' import Image from 'next/image' import clsx from 'clsx' -type Thread = { +// Types +type BuyerThread = { id: number listing_id: number domain: string @@ -36,6 +44,26 @@ type Thread = { closed_reason: string | null } +type SellerInquiry = { + id: number + listing_id: number + domain: string + slug: string + buyer_name: string + buyer_email: string + offer_amount: number | null + status: string + created_at: string + read_at: string | null + replied_at: string | null + closed_at: string | null + closed_reason: string | null + has_unread_reply: boolean + last_message_preview: string + last_message_at: string + last_message_is_buyer: boolean +} + type Message = { id: number inquiry_id: number @@ -45,16 +73,28 @@ type Message = { created_at: string } +type InboxTab = 'buying' | 'selling' + export default function InboxPage() { const { user, subscription, logout, checkAuth } = useStore() const searchParams = useSearchParams() const openInquiryId = searchParams.get('inquiry') + const initialTab = searchParams.get('tab') as InboxTab | null - const [threads, setThreads] = useState([]) - const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState(initialTab || 'buying') + + // Buyer state + const [buyerThreads, setBuyerThreads] = useState([]) + const [loadingBuyer, setLoadingBuyer] = useState(true) + + // Seller state + const [sellerInquiries, setSellerInquiries] = useState([]) + const [loadingSeller, setLoadingSeller] = useState(true) + const [sellerUnread, setSellerUnread] = useState(0) + + // Shared state const [menuOpen, setMenuOpen] = useState(false) - - const [activeThread, setActiveThread] = useState(null) + const [activeThread, setActiveThread] = useState(null) const [messages, setMessages] = useState([]) const [loadingMessages, setLoadingMessages] = useState(false) const [sending, setSending] = useState(false) @@ -65,54 +105,91 @@ export default function InboxPage() { const tierName = subscription?.tier_name || subscription?.tier || 'Scout' const TierIcon = tierName === 'Tycoon' ? Crown : tierName === 'Trader' ? TrendingUp : Zap + const isSeller = tierName !== 'Scout' // Scout can't list domains - const drawerNavSections = [ - { title: 'Discover', items: [ - { href: '/terminal/hunt', label: 'Radar', icon: Target }, - { href: '/terminal/market', label: 'Market', icon: Gavel }, - { href: '/terminal/intel', label: 'Intel', icon: TrendingUp }, - ]}, - { title: 'Manage', items: [ - { href: '/terminal/watchlist', label: 'Watchlist', icon: Eye }, - { href: '/terminal/inbox', label: 'Inbox', icon: MessageSquare, active: true }, - ]}, - ] - - const loadThreads = useCallback(async () => { - setLoading(true) + // Load buyer threads + const loadBuyerThreads = useCallback(async () => { + setLoadingBuyer(true) setError(null) try { const data = await api.getMyInquiryThreads() - setThreads(data) + setBuyerThreads(data) } catch (err: any) { - setError(err?.message || 'Failed to load inbox') + // Silently fail - might not have any threads } finally { - setLoading(false) + setLoadingBuyer(false) } }, []) - useEffect(() => { loadThreads() }, [loadThreads]) + // Load seller inquiries + const loadSellerInquiries = useCallback(async () => { + if (!isSeller) { + setLoadingSeller(false) + return + } + setLoadingSeller(true) + try { + const data = await api.getSellerInbox() + setSellerInquiries(data.inquiries) + setSellerUnread(data.unread) + } catch (err: any) { + // Silently fail + } finally { + setLoadingSeller(false) + } + }, [isSeller]) + useEffect(() => { + loadBuyerThreads() + loadSellerInquiries() + + // Poll inbox counts every 30 seconds for badge updates + const pollInterval = setInterval(() => { + loadBuyerThreads() + loadSellerInquiries() + }, 30000) + + return () => clearInterval(pollInterval) + }, [loadBuyerThreads, loadSellerInquiries]) + + // Handle URL parameter for opening specific inquiry const threadsById = useMemo(() => { - const map = new Map() - threads.forEach(t => map.set(t.id, t)) + const map = new Map() + buyerThreads.forEach(t => map.set(t.id, t)) + sellerInquiries.forEach(t => map.set(t.id, t)) return map - }, [threads]) + }, [buyerThreads, sellerInquiries]) useEffect(() => { if (!openInquiryId) return const id = Number(openInquiryId) if (!Number.isFinite(id)) return const t = threadsById.get(id) - if (t) setActiveThread(t) + if (t) { + setActiveThread(t) + // Determine which tab + if ('buyer_name' in t) { + setActiveTab('selling') + } else { + setActiveTab('buying') + } + } }, [openInquiryId, threadsById]) - const loadMessages = useCallback(async (thread: Thread) => { + // Load messages for thread + const loadMessages = useCallback(async (thread: BuyerThread | SellerInquiry) => { setLoadingMessages(true) setError(null) try { - const data = await api.getInquiryMessagesAsBuyer(thread.id) - setMessages(data) + if ('buyer_name' in thread) { + // Seller loading buyer's messages + const data = await api.getInquiryMessagesAsSeller(thread.listing_id, thread.id) + setMessages(data) + } else { + // Buyer loading their own messages + const data = await api.getInquiryMessagesAsBuyer(thread.id) + setMessages(data) + } } catch (err: any) { setError(err?.message || 'Failed to load messages') } finally { @@ -123,8 +200,16 @@ export default function InboxPage() { useEffect(() => { if (!activeThread) return loadMessages(activeThread) + + // Poll for new messages every 15 seconds when thread is open + const pollInterval = setInterval(() => { + loadMessages(activeThread) + }, 15000) + + return () => clearInterval(pollInterval) }, [activeThread, loadMessages]) + // Send message const sendMessage = useCallback(async () => { if (!activeThread) return const body = draft.trim() @@ -133,7 +218,14 @@ export default function InboxPage() { setSending(true) setError(null) try { - const created = await api.sendInquiryMessageAsBuyer(activeThread.id, body) + let created: Message + if ('buyer_name' in activeThread) { + // Seller replying + created = await api.sendInquiryMessageAsSeller(activeThread.listing_id, activeThread.id, body) + } else { + // Buyer replying + created = await api.sendInquiryMessageAsBuyer(activeThread.id, body) + } setDraft('') setMessages(prev => [...prev, created]) } catch (err: any) { @@ -143,6 +235,27 @@ export default function InboxPage() { } }, [activeThread, draft]) + // Refresh on tab change + useEffect(() => { + setActiveThread(null) + setMessages([]) + setDraft('') + if (activeTab === 'buying') { + loadBuyerThreads() + } else { + loadSellerInquiries() + } + }, [activeTab, loadBuyerThreads, loadSellerInquiries]) + + const isLoading = activeTab === 'buying' ? loadingBuyer : loadingSeller + const currentItems = activeTab === 'buying' ? buyerThreads : sellerInquiries + const emptyMessage = activeTab === 'buying' + ? 'No inquiries yet. Browse Pounce Direct deals and send an inquiry.' + : 'No buyer inquiries yet. List a domain for sale to receive offers.' + const emptyAction = activeTab === 'buying' + ? { href: '/acquire', label: 'Browse Deals' } + : { href: '/terminal/listing', label: 'List Domain' } + return (
@@ -175,141 +288,271 @@ export default function InboxPage() { Inbox

- Your inquiry threads with verified sellers. + Manage your domain inquiries and conversations.

+ {/* TABS */} +
+
+ + {isSeller && ( + + )} +
+
+ + {/* CONTENT */}
- {loading ? ( + {isLoading ? (
- ) : error ? ( + ) : error && !activeThread ? (
{error}
- ) : threads.length === 0 ? ( + ) : currentItems.length === 0 ? (
-

No threads yet

-

Browse Pounce Direct deals and send an inquiry.

- - View Deals +

{emptyMessage}

+ + {emptyAction.label}
) : ( -
- {/* Thread list */} -
-
- Threads +
+ {/* Thread/Inquiry list */} +
+
+ {activeTab === 'buying' ? 'Your Inquiries' : 'Buyer Inquiries'}
- {threads.map(t => ( - + )) + ) : ( + // Seller view + sellerInquiries.map(inq => ( +
- - ))} + + )) + )}
{/* Thread detail */}
{!activeThread ? ( -
Select a thread
+
+ + Select a conversation +
) : ( <> + {/* Thread header */}
-
Thread
-
{activeThread.domain}
+
+ {activeTab === 'buying' ? 'Thread' : 'Inquiry'} +
+
+ {'buyer_name' in activeThread ? activeThread.domain : activeThread.domain} +
+ {'buyer_name' in activeThread && ( +
+ From: {activeThread.buyer_name} ({activeThread.buyer_email}) +
+ )} +
+
+ {'buyer_name' in activeThread && ( + + + + )} + + +
- - View Deal -
-
+ {/* Messages */} +
{loadingMessages ? (
) : messages.length === 0 ? ( -
No messages
+
No messages yet
) : ( - messages.map(m => ( -
-
- {m.sender_user_id === user?.id ? 'You' : 'Seller'} - {new Date(m.created_at).toLocaleString('en-US')} + messages.map(m => { + const isMe = m.sender_user_id === user?.id + return ( +
+
+ {isMe ? 'You' : (activeTab === 'buying' ? 'Seller' : 'Buyer')} + {new Date(m.created_at).toLocaleString('en-US')} +
+
{m.body}
-
{m.body}
-
- )) + ) + }) )}
+ {/* Reply form */}
{activeThread.status === 'closed' || activeThread.status === 'spam' ? (
- Thread is closed. + This conversation is closed.
) : (