diff --git a/src/app/profile/[user]/components/main-panel.tsx b/src/app/profile/[user]/components/main-panel.tsx index efa1c41c..dab525b2 100644 --- a/src/app/profile/[user]/components/main-panel.tsx +++ b/src/app/profile/[user]/components/main-panel.tsx @@ -10,6 +10,7 @@ import { fetchAccount } from 'ethereum-identity-kit' import { useIsClient, useWindowSize } from 'ethereum-identity-kit' import ActivityPanel from './activity' import BrokerPanel from './brokerPanel' +import PrivateForMePanel from './privateForMePanel' import { useQuery } from '@tanstack/react-query' import OfferPanel from './offerPanel' import { changeTab, selectUserProfile, setLastVisitedProfile } from '@/state/reducers/portfolio/profile' @@ -95,9 +96,9 @@ const MainPanel: React.FC = ({ user }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]) - // ensure that only the owner of the profile can see the watchlist + // ensure that only the owner of the profile can see the watchlist and private_for_me tabs useEffect(() => { - if (profileTab === 'watchlist') { + if (profileTab === 'watchlist' || profileTab === 'private_for_me') { if ( !( userAddress && @@ -119,6 +120,7 @@ const MainPanel: React.FC = ({ user }) => { const showOfferPanel = profileTab === 'sent_offers' || profileTab === 'received_offers' const showActivityPanel = profileTab === 'activity' const showBrokerPanel = profileTab === 'broker' + const showPrivateForMePanel = profileTab === 'private_for_me' return ( @@ -140,6 +142,7 @@ const MainPanel: React.FC = ({ user }) => { showOfferPanel={showOfferPanel} showActivityPanel={showActivityPanel} showBrokerPanel={showBrokerPanel} + showPrivateForMePanel={showPrivateForMePanel} isMyProfile={isMyProfile} /> @@ -159,6 +162,7 @@ interface ProfileContentProps { showOfferPanel: boolean showActivityPanel: boolean showBrokerPanel: boolean + showPrivateForMePanel: boolean isMyProfile: boolean } @@ -168,6 +172,7 @@ const ProfileContent: React.FC = ({ showOfferPanel, showActivityPanel, showBrokerPanel, + showPrivateForMePanel, isMyProfile, }) => { const isClient = useIsClient() @@ -187,6 +192,7 @@ const ProfileContent: React.FC = ({ {showOfferPanel && } {showActivityPanel && } {showBrokerPanel && } + {showPrivateForMePanel && } ) } diff --git a/src/app/profile/[user]/components/privateForMePanel.tsx b/src/app/profile/[user]/components/privateForMePanel.tsx new file mode 100644 index 00000000..f02c6ab3 --- /dev/null +++ b/src/app/profile/[user]/components/privateForMePanel.tsx @@ -0,0 +1,67 @@ +'use client' + +import React from 'react' +import { Address } from 'viem' +import Domains from '@/components/domains' +import ViewSelector from '@/components/domains/viewSelector' +import { usePrivateListings } from '../hooks/usePrivateListings' +import { PORTFOLIO_PRIVATE_FOR_ME_DISPLAYED_COLUMNS } from '@/constants/domains/marketplaceDomains' +import { cn } from '@/utils/tailwind' +import { useNavbar } from '@/context/navbar' +import { useFilterRouter } from '@/hooks/filters/useFilterRouter' + +interface Props { + user: Address | undefined +} + +const PrivateForMePanel: React.FC = ({ user }) => { + const { isNavbarVisible } = useNavbar() + const { selectors } = useFilterRouter() + + const { + privateListings, + isPrivateListingsLoading, + fetchMorePrivateListings, + hasMorePrivateListings, + totalPrivateListings, + } = usePrivateListings(user) + + return ( +
+
+
+

+ {totalPrivateListings} private {totalPrivateListings === 1 ? 'listing' : 'listings'} for you +

+
+
+ +
+
+ { + if (hasMorePrivateListings && !isPrivateListingsLoading) { + fetchMorePrivateListings() + } + }} + displayedDetails={PORTFOLIO_PRIVATE_FOR_ME_DISPLAYED_COLUMNS} + showWatchlist={false} + isBulkSelecting={false} + /> +
+ ) +} + +export default PrivateForMePanel diff --git a/src/app/profile/[user]/components/tabSwitcher.tsx b/src/app/profile/[user]/components/tabSwitcher.tsx index fd62a895..e3f17b1a 100644 --- a/src/app/profile/[user]/components/tabSwitcher.tsx +++ b/src/app/profile/[user]/components/tabSwitcher.tsx @@ -11,6 +11,7 @@ import { useUserContext } from '@/context/user' import { useOffers } from '../hooks/useOffers' import { useDomains } from '../hooks/useDomains' import { useBrokeredListings } from '../hooks/useBrokeredListings' +import { usePrivateListings } from '../hooks/usePrivateListings' import { useNavbar } from '@/context/navbar' import { formatTotalTabItems } from '@/utils/formatTabItems' import { useFilterRouter } from '@/hooks/filters/useFilterRouter' @@ -28,6 +29,7 @@ const TabSwitcher: React.FC = ({ user }) => { useDomains(user) const { totalReceivedOffers, totalSentOffers } = useOffers(user) const { totalActiveBrokeredListings, totalBrokeredListings } = useBrokeredListings(user) + const { totalPrivateListings } = usePrivateListings(user) const { isNavbarVisible } = useNavbar() const { actions } = useFilterRouter() @@ -52,6 +54,16 @@ const TabSwitcher: React.FC = ({ user }) => { if (tab.value === 'watchlist') { return user && userAddress && user.toLowerCase() === userAddress.toLowerCase() && authStatus === 'authenticated' } + // Only show private_for_me tab to profile owner and if there are private listings + if (tab.value === 'private_for_me') { + return ( + user && + userAddress && + user.toLowerCase() === userAddress.toLowerCase() && + authStatus === 'authenticated' && + totalPrivateListings > 0 + ) + } // Only show broker tab if there are brokered listings if (tab.value === 'broker') { return totalBrokeredListings > 0 @@ -89,6 +101,7 @@ const TabSwitcher: React.FC = ({ user }) => { totalExpiredDomains, totalReceivedOffers, totalSentOffers, + totalPrivateListings, ]) const getTotalItems = useMemo( @@ -110,6 +123,8 @@ const TabSwitcher: React.FC = ({ user }) => { return formatTotalTabItems(totalSentOffers) case 'broker': return formatTotalTabItems(totalActiveBrokeredListings || 0) + case 'private_for_me': + return formatTotalTabItems(totalPrivateListings) case 'activity': return 0 } @@ -123,6 +138,7 @@ const TabSwitcher: React.FC = ({ user }) => { totalGraceDomains, totalExpiredDomains, totalActiveBrokeredListings, + totalPrivateListings, ] ) diff --git a/src/app/profile/[user]/hooks/usePrivateListings.ts b/src/app/profile/[user]/hooks/usePrivateListings.ts new file mode 100644 index 00000000..31b87e7f --- /dev/null +++ b/src/app/profile/[user]/hooks/usePrivateListings.ts @@ -0,0 +1,137 @@ +import { Address } from 'viem' +import { useInfiniteQuery } from '@tanstack/react-query' +import { useUserContext } from '@/context/user' +import { authFetch } from '@/api/authFetch' +import { DEFAULT_FETCH_LIMIT } from '@/constants/api' +import { MarketplaceDomainType, DomainListingType } from '@/types/domains' + +interface PrivateListingApiResponse { + id: number + ens_name: string + ens_name_id: number + token_id: string + seller_address: string + price_wei: string + currency_address: string + status: string + created_at: string + expires_at: string | null + name_expiry_date: string | null + current_owner: string + order_hash: string + order_data: any + source: string +} + +interface PrivateListingsResponse { + success: boolean + data: { + listings: PrivateListingApiResponse[] + pagination: { + page: number + limit: number + total: number + totalPages: number + hasNext: boolean + hasPrev: boolean + } + } +} + +// Transform API response to MarketplaceDomainType format +const transformToMarketplaceDomain = (listing: PrivateListingApiResponse): MarketplaceDomainType => { + const domainListing: DomainListingType = { + id: listing.id, + price: listing.price_wei, + price_wei: listing.price_wei, + currency_address: listing.currency_address as Address, + status: listing.status, + seller_address: listing.seller_address, + order_hash: listing.order_hash, + order_data: listing.order_data, + expires_at: listing.expires_at || '', + created_at: listing.created_at, + source: listing.source || 'grails', + broker_address: null, + broker_fee_bps: null, + } + + return { + id: listing.ens_name_id || listing.id, + name: listing.ens_name, + token_id: listing.token_id, + owner: listing.current_owner as Address, + expiry_date: listing.name_expiry_date, + registration_date: null, + metadata: {}, + has_numbers: /\d/.test(listing.ens_name), + has_emoji: false, + clubs: [], + listings: [domainListing], + highest_offer_wei: null, + highest_offer_id: null, + highest_offer_currency: null, + offer: null, + last_sale_price: null, + last_sale_price_usd: null, + last_sale_currency: null, + last_sale_date: null, + view_count: 0, + watchers_count: 0, + downvotes: 0, + upvotes: 0, + watchlist_record_id: null, + } +} + +export const usePrivateListings = (user: Address | undefined) => { + const { userAddress, authStatus } = useUserContext() + + const isMyProfile = + !!user && !!userAddress && user.toLowerCase() === userAddress.toLowerCase() && authStatus === 'authenticated' + + const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: ['profile', 'private_listings', userAddress], + queryFn: async ({ pageParam = 1 }) => { + if (!isMyProfile) { + return { + domains: [] as MarketplaceDomainType[], + total: 0, + nextPage: null as number | null, + } + } + + const response = await authFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/listings/private-for-me?page=${pageParam}&limit=${DEFAULT_FETCH_LIMIT}` + ) + + if (!response.ok) { + throw new Error('Failed to fetch private listings') + } + + const data: PrivateListingsResponse = await response.json() + + return { + domains: data.data.listings.map(transformToMarketplaceDomain), + total: data.data.pagination.total, + nextPage: data.data.pagination.hasNext ? pageParam + 1 : null, + } + }, + getNextPageParam: (lastPage) => lastPage.nextPage, + initialPageParam: 1, + enabled: isMyProfile, + staleTime: 30000, + }) + + const privateListings = data?.pages?.flatMap((page) => page.domains) ?? [] + const totalPrivateListings = data?.pages?.[0]?.total ?? 0 + + return { + privateListings, + isPrivateListingsLoading: isLoading, + isPrivateListingsFetchingNextPage: isFetchingNextPage, + fetchMorePrivateListings: fetchNextPage, + hasMorePrivateListings: !!hasNextPage, + totalPrivateListings, + } +} diff --git a/src/components/modal/listing/createListingModal.tsx b/src/components/modal/listing/createListingModal.tsx index 7a7b9ccd..2a7c0486 100644 --- a/src/components/modal/listing/createListingModal.tsx +++ b/src/components/modal/listing/createListingModal.tsx @@ -85,6 +85,11 @@ const CreateListingModal: React.FC = ({ onClose, domain const [minBrokerFeePercent, setMinBrokerFeePercent] = useState(0.0001) // Default 1% const [showBrokerSection, setShowBrokerSection] = useState(false) + // Private listing fields + const [showPrivateSection, setShowPrivateSection] = useState(false) + const [privateBuyerAddress, setPrivateBuyerAddress] = useState('') + const [privateBuyerAddressInput, setPrivateBuyerAddressInput] = useState('') + const debouncedBrokerAddress = useDebounce(brokerAddress, 500) const { data: brokerAccount, isLoading: isBrokerAccountLoading } = useQuery({ @@ -100,6 +105,22 @@ const CreateListingModal: React.FC = ({ onClose, domain enabled: !!debouncedBrokerAddress, }) + // Private buyer address resolution + const debouncedPrivateBuyerInput = useDebounce(privateBuyerAddressInput, 500) + + const { data: privateBuyerAccount, isLoading: isPrivateBuyerAccountLoading } = useQuery({ + queryKey: ['account', debouncedPrivateBuyerInput], + queryFn: async () => { + if (!isAddress(debouncedPrivateBuyerInput) && !debouncedPrivateBuyerInput.includes('.')) return null + + const response = await fetchAccount(debouncedPrivateBuyerInput) + if (!isAddress(response?.address ?? '')) return null + + return response + }, + enabled: !!debouncedPrivateBuyerInput, + }) + useEffect(() => { getCurrentChain() // eslint-disable-next-line react-hooks/exhaustive-deps @@ -123,15 +144,29 @@ const CreateListingModal: React.FC = ({ onClose, domain fetchBrokerConfig() }, []) - // Clear broker fields when Grails is deselected + // Clear broker and private listing fields when Grails is deselected useEffect(() => { if (!selectedMarketplace.includes('grails')) { setBrokerAddress('') setBrokerFeePercent('') setShowBrokerSection(false) + setShowPrivateSection(false) + setPrivateBuyerAddress('') + setPrivateBuyerAddressInput('') } }, [selectedMarketplace]) + // Sync private buyer address from resolved account + useEffect(() => { + if (privateBuyerAccount?.address) { + setPrivateBuyerAddress(privateBuyerAccount.address) + } else if (isAddress(debouncedPrivateBuyerInput)) { + setPrivateBuyerAddress(debouncedPrivateBuyerInput) + } else { + setPrivateBuyerAddress('') + } + }, [privateBuyerAccount, debouncedPrivateBuyerInput]) + useEffect(() => { domains.forEach((domain, index) => { const previousListing = previousListings.find((listing) => @@ -226,6 +261,20 @@ const CreateListingModal: React.FC = ({ onClose, domain } } + // Validate private buyer address + if (showPrivateSection && privateBuyerAddressInput.length > 0) { + if (!privateBuyerAddress) { + setError('Invalid private buyer address') + setStatus('error') + return + } + if (privateBuyerAddress.toLowerCase() === userAddress.toLowerCase()) { + setError('Cannot create a private listing for yourself') + setStatus('error') + return + } + } + try { const params: any = { domains, @@ -245,6 +294,11 @@ const CreateListingModal: React.FC = ({ onClose, domain params.brokerFeeBps = Math.round(Number(brokerFeePercent) * 100) // Convert percent to basis points } + // Add private buyer address if specified + if (showPrivateSection && privateBuyerAddress) { + params.privateBuyerAddress = privateBuyerAddress + } + // console.log('Params:', params) const result = await createListing(params) @@ -685,6 +739,77 @@ const CreateListingModal: React.FC = ({ onClose, domain )} + {/* Private Listing section - only show when only Grails is selected */} + {selectedMarketplace.length === 1 && selectedMarketplace[0] === 'grails' && ( +
+
setShowPrivateSection(!showPrivateSection)} + className='flex cursor-pointer items-center justify-between' + > +
+
+

Private Listing

+

(Optional)

+
+

Only the specified buyer can purchase

+
+ Arrow +
+ + {showPrivateSection && ( +
+
+ setPrivateBuyerAddressInput(e.target.value)} + placeholder='ENS or Address' + /> + {debouncedPrivateBuyerInput.length > 0 && + (isPrivateBuyerAccountLoading ? ( +
+ + +
+ ) : privateBuyerAccount ? ( +
+ +

+ {isAddress(privateBuyerAddressInput) && privateBuyerAccount.ens.name + ? beautifyName(privateBuyerAccount.ens.name) + : privateBuyerAccount.address || privateBuyerAddressInput} +

+
+ ) : isAddress(privateBuyerAddressInput) ? null : ( +

Invalid ENS name or Address

+ ))} +
+ + {privateBuyerAddress && ( +
+

+ This listing will only be visible to the specified buyer. +

+
+ )} +
+ )} +
+ )} +
= { domain: { diff --git a/src/constants/domains/portfolio/tabs.ts b/src/constants/domains/portfolio/tabs.ts index b3b4c27c..854eee68 100644 --- a/src/constants/domains/portfolio/tabs.ts +++ b/src/constants/domains/portfolio/tabs.ts @@ -27,6 +27,10 @@ export const PROFILE_TABS = [ label: 'Watchlist', value: 'watchlist', }, + { + label: 'Private For Me', + value: 'private_for_me', + }, { label: 'Broker', value: 'broker', diff --git a/src/context/seaport.tsx b/src/context/seaport.tsx index 772d9843..d5a803d1 100644 --- a/src/context/seaport.tsx +++ b/src/context/seaport.tsx @@ -26,6 +26,7 @@ type SeaportContextValue = { currencies?: ('ETH' | 'USDC')[] brokerAddress?: string // Address to receive broker fee brokerFeeBps?: number // Broker fee in basis points (e.g., 100 = 1%) + privateBuyerAddress?: string // Address for private listing (only this buyer can purchase) setStatus?: (status: ListingStatus) => void setApproveTxHash?: (txHash: string | null) => void setCreateListingTxHash?: (txHash: string | null) => void diff --git a/src/hooks/useSeaportClient.ts b/src/hooks/useSeaportClient.ts index 4bdb50f4..e0c51056 100644 --- a/src/hooks/useSeaportClient.ts +++ b/src/hooks/useSeaportClient.ts @@ -80,6 +80,7 @@ export function useSeaportClient() { currencies?: ('ETH' | 'USDC')[] brokerAddress?: string // Address to receive broker fee brokerFeeBps?: number // Broker fee in basis points (e.g., 100 = 1%) + privateBuyerAddress?: string // Address for private listing (only this buyer can purchase) setStatus?: (status: ListingStatus) => void setApproveTxHash?: (txHash: string | null) => void setCreateListingTxHash?: (txHash: string | null) => void @@ -191,6 +192,7 @@ export function useSeaportClient() { broker_address: params.brokerAddress, broker_fee_bps: params.brokerFeeBps, expires_at: new Date(params.expiryDate * 1000).toISOString(), + private_buyer_address: params.privateBuyerAddress || null, }), }) }) @@ -221,6 +223,7 @@ export function useSeaportClient() { currencies: params.currencies, orders: grailsOrders, seller_address: address, + private_buyer_address: params.privateBuyerAddress || null, }), }) } @@ -289,6 +292,7 @@ export function useSeaportClient() { broker_address: params.brokerAddress, broker_fee_bps: params.brokerFeeBps, expires_at: new Date(params.expiryDate * 1000).toISOString(), + private_buyer_address: params.privateBuyerAddress || null, }), }) }) @@ -319,6 +323,7 @@ export function useSeaportClient() { currencies: params.currencies, orders: formattedOrders, seller_address: address, + private_buyer_address: params.privateBuyerAddress || null, }), })