diff --git a/backend/app/api/listings.py b/backend/app/api/listings.py index 0f830b3..be83e83 100644 --- a/backend/app/api/listings.py +++ b/backend/app/api/listings.py @@ -423,9 +423,10 @@ async def submit_inquiry( slug: str, inquiry: InquiryCreate, request: Request, + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Submit an inquiry for a listing (public).""" + """Submit an inquiry for a listing (requires authentication).""" # Find listing result = await db.execute( select(DomainListing).where( @@ -439,6 +440,14 @@ async def submit_inquiry( if not listing: raise HTTPException(status_code=404, detail="Listing not found") + + # Require that inquiries are sent from the authenticated account email. + # This prevents anonymous spam and makes the buyer identity consistent. + if inquiry.email.lower() != (current_user.email or "").lower(): + raise HTTPException( + status_code=400, + detail="Inquiry email must match your account email.", + ) # Security: Check for phishing keywords if not _check_content_safety(inquiry.message): diff --git a/frontend/src/app/acquire/page.tsx b/frontend/src/app/acquire/page.tsx index 8e031ff..55cf2cf 100644 --- a/frontend/src/app/acquire/page.tsx +++ b/frontend/src/app/acquire/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, useCallback } from 'react' import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { Header } from '@/components/Header' @@ -25,6 +25,7 @@ import { Loader2 } from 'lucide-react' import Link from 'next/link' +import { useRouter } from 'next/navigation' import clsx from 'clsx' interface MarketItem { @@ -64,6 +65,7 @@ interface Auction { tld: string affiliate_url: string is_pounce?: boolean + slug?: string } type TabType = 'all' | 'ending' | 'hot' @@ -112,6 +114,7 @@ function isVanityDomain(auction: Auction): boolean { export default function AcquirePage() { const { isAuthenticated, checkAuth, isLoading: authLoading } = useStore() + const router = useRouter() const [allAuctions, setAllAuctions] = useState([]) const [endingSoon, setEndingSoon] = useState([]) @@ -127,6 +130,8 @@ export default function AcquirePage() { const [selectedPlatform, setSelectedPlatform] = useState('All') const [searchFocused, setSearchFocused] = useState(false) const [showFilters, setShowFilters] = useState(false) + + const [directGate, setDirectGate] = useState<{ domain: string; slug: string } | null>(null) useEffect(() => { checkAuth() @@ -158,6 +163,7 @@ export default function AcquirePage() { age_years: null, tld: item.tld, affiliate_url: item.url, + slug: item.slug, }) const toAuctions = (items: MarketItem[]) => items.map(convertToAuction) @@ -224,6 +230,7 @@ export default function AcquirePage() { tld: item.tld, affiliate_url: item.url, is_pounce: true, // Special flag + slug: item.slug, })) // Apply auth filter @@ -254,6 +261,23 @@ export default function AcquirePage() { return 'text-white/40' } + const getPounceSlug = useCallback((auction: Auction): string | null => { + if (!auction.is_pounce) return null + if (auction.slug) return auction.slug + const match = (auction.affiliate_url || '').match(/^\/buy\/(.+)$/) + return match?.[1] || null + }, []) + + const handlePounceDirectClick = useCallback((auction: Auction) => { + const slug = getPounceSlug(auction) + if (!slug) return + if (isAuthenticated) { + router.push(`/buy/${slug}`) + return + } + setDirectGate({ domain: auction.domain, slug }) + }, [getPounceSlug, isAuthenticated, router]) + if (authLoading) { return (
@@ -278,6 +302,72 @@ export default function AcquirePage() {
+ {/* Pounce Direct gate (public conversion modal) */} + {directGate && ( +
+ +
+
+
+ +
+

Seller protection

+

+ Log in to send an inquiry. This reduces spam and keeps buyer identity consistent. +

+
+
+ + + View Details + + +
+ + Login + + + + Register + + +
+
+
+ + )} + {/* ═══════════════════════════════════════════════════════════════════════ */} {/* MOBILE HEADER */} {/* ═══════════════════════════════════════════════════════════════════════ */} @@ -425,61 +515,82 @@ export default function AcquirePage() { ) : (
{filteredAuctions.slice(0, 50).map((auction, i) => ( - -
-
-
- {auction.is_pounce && ( + auction.is_pounce ? ( + + ) : ( + +
+
+
{auction.domain}
- {auction.is_pounce ? ( - - Verified • Instant - - ) : ( - <> - {auction.platform} - - - - {calcTimeRemaining(auction.end_time)} - - - )} + {auction.platform} + + + + {calcTimeRemaining(auction.end_time)} +
{formatCurrency(auction.current_bid)}
- {auction.is_pounce ? ( -
Buy Now
- ) : auction.num_bids > 0 && ( + {auction.num_bids > 0 && (
{auction.num_bids} bids
)}
-
+ + ) ))}
)} @@ -617,41 +728,33 @@ export default function AcquirePage() { ) : (
{filteredAuctions.map((auction, i) => ( - -
- {auction.is_pounce && ( -
- - Direct -
+ auction.is_pounce ? ( +
-
+ + ) : ( + +
+ + {auction.domain} + +
+
{auction.platform}
+
+ {formatCurrency(auction.current_bid)} +
+
+ {calcTimeRemaining(auction.end_time)} +
+
+
+ +
+
+
+ ) ))}
)} diff --git a/frontend/src/app/buy/[slug]/page.tsx b/frontend/src/app/buy/[slug]/page.tsx index a923ba7..e748b1b 100644 --- a/frontend/src/app/buy/[slug]/page.tsx +++ b/frontend/src/app/buy/[slug]/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, memo } from 'react' import { useParams } from 'next/navigation' +import { useStore } from '@/lib/store' import { api } from '@/lib/api' import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' @@ -65,6 +66,7 @@ Tooltip.displayName = 'Tooltip' export default function BuyDomainPage() { const params = useParams() const slug = params.slug as string + const { isAuthenticated, checkAuth, user, isLoading: authLoading } = useStore() const [listing, setListing] = useState(null) const [loading, setLoading] = useState(true) @@ -83,9 +85,20 @@ export default function BuyDomainPage() { }) useEffect(() => { + checkAuth() loadListing() }, [slug]) + // Prefill inquiry identity from authenticated user (no overrides once typed) + useEffect(() => { + if (!isAuthenticated || !user) return + setFormData(prev => ({ + ...prev, + name: prev.name || (user.name || ''), + email: prev.email || user.email || '', + })) + }, [isAuthenticated, user]) + const loadListing = async () => { setLoading(true) setError(null) @@ -160,7 +173,7 @@ export default function BuyDomainPage() { The domain you are looking for has been sold, removed, or is temporarily unavailable.

Browse Marketplace @@ -331,41 +344,88 @@ export default function BuyDomainPage() {
- {/* Always Visible Form */} -
-
-

- {listing.asking_price ? 'Purchase Inquiry' : 'Contact Seller'} -

-
- -
-
- setFormData({ ...formData, name: e.target.value })} - className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all" - /> - setFormData({ ...formData, email: e.target.value })} - className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all" - /> + {/* Auth Gate: inquiries require login */} + {authLoading ? ( +
+ +
+ ) : !isAuthenticated ? ( +
+
+
+
+ +
+
+

Contact requires login

+

+ To protect sellers from spam, inquiries are only available for logged-in accounts. +

+
+
+
+ + Login + + + + Create Account + + +
+
+ ) : ( + +
+

+ {listing.asking_price ? 'Purchase Inquiry' : 'Contact Seller'} +

+
+ +
+
+ setFormData({ ...formData, name: e.target.value })} + className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all" + /> + setFormData({ ...formData, email: e.target.value })} + readOnly={isAuthenticated} + title={isAuthenticated ? 'Uses your account email' : undefined} + className={clsx( + "w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all", + isAuthenticated && "opacity-70 cursor-not-allowed" + )} + /> +
+ {isAuthenticated && ( +

+ Inquiries are sent from your account email. +

+ )} setFormData({ ...formData, phone: e.target.value })} - className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all" + type="text" + placeholder="Phone (Optional)" + value={formData.phone} + onChange={(e) => setFormData({ ...formData, phone: e.target.value })} + className="w-full bg-zinc-900/50 border border-white/10 rounded-lg px-4 py-3 text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-emerald-500/50 transition-all" /> - {listing.allow_offers && ( + {listing.allow_offers && (
$ )}