diff --git a/src/app/[name]/components/activity.tsx b/src/app/[name]/components/activity.tsx index 965db2ac..1db3a44d 100644 --- a/src/app/[name]/components/activity.tsx +++ b/src/app/[name]/components/activity.tsx @@ -18,9 +18,10 @@ const ActivityPanel: React.FC = ({ name }) => { 'sm:border-tertiary bg-secondary pt-lg lg:pt-xl flex w-full flex-col gap-1 sm:rounded-lg sm:border-2 lg:gap-4', isActivityEmpty && 'pb-6' )} + style={{ maxHeight: '580px', overflow: 'hidden' }} >

Activity

-
+
= ({ name }) => {
+
diff --git a/src/app/[name]/components/similarNames.tsx b/src/app/[name]/components/similarNames.tsx new file mode 100644 index 00000000..358dd74e --- /dev/null +++ b/src/app/[name]/components/similarNames.tsx @@ -0,0 +1,77 @@ +'use client' + +import React from 'react' +import { useSimilarNames } from '../hooks/useSimilarNames' +import TableRow from '@/components/domains/table/components/TableRow' +import { MarketplaceHeaderColumn } from '@/types/domains' +import { cn } from '@/utils/tailwind' +import LoadingCell from '@/components/ui/loadingCell' + +interface Props { + name: string + categories?: string[] +} + +// Columns to display in list view for similar names +const SIMILAR_NAMES_COLUMNS: MarketplaceHeaderColumn[] = ['domain', 'price', 'owner', 'actions'] + +const SimilarNames: React.FC = ({ name, categories }) => { + const { domains, isLoading, loadingPhase, error } = useSimilarNames(name, categories) + + // Don't render if there's an error or no results after loading + if (error || (!isLoading && domains.length === 0)) { + return null + } + + return ( +
+
+

Recommended

+ GrailsAI (beta) +
+ +
+ {/* Table Header */} +
+ {SIMILAR_NAMES_COLUMNS.map((column) => ( +
+ {column === 'domain' ? 'Name' : column === 'price' ? 'Price' : column === 'owner' ? 'Owner' : ''} +
+ ))} +
+ + {/* Loading overlay - maintains same height as 20 rows */} + {isLoading ? ( +
+ + + {loadingPhase === 'ai' ? 'AI Thinking...' : 'Loading name data...'} + +
+ ) : ( + // Loaded - show domain rows + domains.slice(0, 10).map((domain, index) => ( + + )) + )} +
+
+ ) +} + +export default SimilarNames + diff --git a/src/app/[name]/hooks/useSimilarNames.ts b/src/app/[name]/hooks/useSimilarNames.ts new file mode 100644 index 00000000..66bc0021 --- /dev/null +++ b/src/app/[name]/hooks/useSimilarNames.ts @@ -0,0 +1,145 @@ +import { useQuery } from '@tanstack/react-query' +import { MarketplaceDomainType } from '@/types/domains' +import { API_URL } from '@/constants/api' +import { APIResponseType, PaginationType } from '@/types/api' +import { hexToBigInt, labelhash } from 'viem' + +interface SimilarNamesAPIResponse { + suggestions: string[] + error?: string +} + +/** + * Fetches AI-generated similar name suggestions from the API + */ +async function fetchSimilarNames(name: string, categories?: string[]): Promise { + let url = `/api/ai/similar-names?name=${encodeURIComponent(name)}` + if (categories && categories.length > 0) { + url += `&categories=${encodeURIComponent(categories.join(','))}` + } + + const response = await fetch(url) + + if (!response.ok) { + throw new Error('Failed to fetch similar names') + } + + const data: SimilarNamesAPIResponse = await response.json() + return data.suggestions || [] +} + +/** + * Fetches domain details for multiple names in a single bulk request + */ +async function fetchDomainsForNames(names: string[]): Promise { + try { + const res = await fetch(`${API_URL}/search/bulk-filters`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + terms: names, + page: 1, + limit: names.length, + }), + }) + + if (!res.ok) { + throw new Error('Bulk fetch failed') + } + + const json = (await res.json()) as APIResponseType<{ + names: MarketplaceDomainType[] + results: MarketplaceDomainType[] + pagination: PaginationType + }> + + const domains = json.data.names || json.data.results || [] + + // Insert placeholders for names not returned by the DB (same pattern as fetchDomains) + for (const name of names) { + const domainName = name + '.eth' + if (!domains.some((d) => d.name === domainName)) { + domains.push({ + id: 0, + name: domainName, + token_id: hexToBigInt(labelhash(name)).toString(), + expiry_date: null, + registration_date: null, + owner: null, + metadata: {}, + has_numbers: false, + has_emoji: false, + listings: [], + clubs: [], + highest_offer_wei: null, + highest_offer_id: null, + highest_offer_currency: null, + last_sale_price_usd: null, + offer: null, + last_sale_price: null, + last_sale_currency: null, + last_sale_date: null, + view_count: 0, + watchers_count: 0, + downvotes: 0, + upvotes: 0, + watchlist_record_id: null, + }) + } + } + + return domains + } catch (error) { + console.error('Error bulk fetching domain details:', error) + return [] + } +} + +/** + * Hook to get AI-suggested similar names with full domain data + * @param ensName - The ENS name to find similar names for + * @param categories - Optional array of category/club names for context + */ +export const useSimilarNames = (ensName: string, categories?: string[]) => { + // First, fetch the AI suggestions + const { + data: suggestions, + isLoading: suggestionsLoading, + error: suggestionsError, + } = useQuery({ + queryKey: ['similar-names', 'suggestions', ensName, categories], + queryFn: () => fetchSimilarNames(ensName, categories), + enabled: !!ensName && ensName.length > 0, + staleTime: 1000 * 60 * 60, // 1 hour - matches server cache + refetchOnWindowFocus: false, + }) + + // Then, fetch domain details for each suggestion + const { + data: domains, + isLoading: domainsLoading, + error: domainsError, + } = useQuery({ + queryKey: ['similar-names', 'domains', suggestions], + queryFn: () => fetchDomainsForNames(suggestions || []), + enabled: !!suggestions && suggestions.length > 0, + staleTime: 1000 * 60 * 5, // 5 minutes for domain data + refetchOnWindowFocus: false, + }) + + const isLoading = suggestionsLoading || (!!suggestions && suggestions.length > 0 && domainsLoading) + const loadingPhase = suggestionsLoading ? 'ai' : domainsLoading ? 'domains' : null + const error = suggestionsError || domainsError + + return { + domains: domains || [], + suggestions: suggestions || [], + isLoading, + loadingPhase, + error, + } +} + diff --git a/src/app/api/ai/similar-names/route.ts b/src/app/api/ai/similar-names/route.ts new file mode 100644 index 00000000..5f45fafe --- /dev/null +++ b/src/app/api/ai/similar-names/route.ts @@ -0,0 +1,207 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ens_normalize } from '@adraffy/ens-normalize' + +// Simple in-memory cache with 1 hour TTL +const cache = new Map() +const CACHE_TTL = 60 * 60 * 1000 // 1 hour in milliseconds + +interface SimilarNamesResponse { + suggestions: string[] + error?: string +} + +const SYSTEM_PROMPT = `given an input string, return exactly 10 results that are related and likely to be similarly or more valuable than the input. +Rules (strict!): +3–16 chars per result +No spaces in any result +If input is single word → results = single words only +Digits-only input → all results digits, same length, similar pattern +PG-13 only +results must not contain “.” +Emojis-only input → output emojis-only; if input repeats, results repeat too +If input implies a category/theme → stay on-theme +order the results by highest recognition first. +Return no other data.` + +/** + * Attempts to normalize a name for ENS validity. + * Returns the normalized name if valid, or null if it can't be healed. + */ +function tryNormalizeName(name: string): string | null { + // Step 1: Basic cleanup - remove spaces, trim, lowercase + let cleaned = name.replaceAll(' ', '').trim().toLowerCase() + + // Step 2: Remove any dots (not allowed in our suggestions) + cleaned = cleaned.replaceAll('.', '') + + // Step 3: Skip empty or too short/long + if (cleaned.length === 0 || cleaned.length > 16) { + return null + } + + // Step 4: Try to normalize with ENS library + try { + const normalized = ens_normalize(cleaned) + // Ensure normalized result is still within bounds + if (normalized.length > 0 && normalized.length <= 16) { + return normalized + } + return null + } catch { + // Name cannot be normalized - invalid for ENS + return null + } +} + +// Categories to exclude from AI prompt (not useful for suggestions) +const EXCLUDED_CATEGORIES = [ + 'prepunks', + 'prepunk_100', + 'prepunk_10k', + 'prepunk_1k', + 'prepunk_digits', +] + +async function callOpenAI(name: string, categories?: string[]): Promise { + // Filter out excluded categories + const filteredCategories = categories?.filter( + (cat) => !EXCLUDED_CATEGORIES.includes(cat.toLowerCase()) + ) + + // Build input with optional categories context + let input = `name: ${name}` + if (filteredCategories && filteredCategories.length > 0) { + input += `\ncategories: ${filteredCategories.join(', ')}` + } + + const t0 = performance.now() + const response = await fetch('https://api.openai.com/v1/responses', { + method: 'POST', + headers: { + Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-5.2-2025-12-11', + instructions: SYSTEM_PROMPT, + input, + max_output_tokens: 1000, + store: true, + reasoning: { + effort: 'none', + }, + text: { + format: { type: 'text' }, + verbosity: 'low', + }, + }), + }) + const t1 = performance.now() + + const data = await response.json() + const t2 = performance.now() + console.log(`[similar-names] fetch: ${(t1 - t0).toFixed(0)}ms | parse: ${(t2 - t1).toFixed(0)}ms | reasoning_tokens: ${data.usage?.output_tokens_details?.reasoning_tokens ?? '?'} | output_tokens: ${data.usage?.output_tokens ?? '?'}`) + + // Check if response is completed (allow incomplete if we have output) + if (data.status !== 'completed' && data.status !== 'incomplete') { + console.error('OpenAI response failed:', data.status, data.error) + throw new Error(`OpenAI response status: ${data.status}`) + } + + // If incomplete, log but continue to try extracting content + if (data.status === 'incomplete') { + console.warn('OpenAI response incomplete, attempting to extract partial content:', data.incomplete_details) + } + + // Find the message item in output (may have reasoning item before it) + const messageItem = data.output?.find((item: { type: string }) => item.type === 'message') + if (!messageItem) { + console.error('No message item in OpenAI response:', data) + throw new Error('No message item in OpenAI response') + } + + // Extract text from the message content + const text = messageItem.content?.find((c: { type: string }) => c.type === 'output_text')?.text + if (!text) { + console.error('No text in OpenAI response message:', messageItem) + throw new Error('No text in OpenAI response') + } + + // Parse the response - split by newlines or commas, strip leading list numbers (e.g. "1.", "2)", "3 ") + const rawSuggestions = text + .split(/[\n,]+/) + .map((s: string) => s.trim().replace(/^\d+[.):\-\s]+/, '').trim()) + .filter((s: string) => s.length > 0) + + // Normalize and validate each suggestion + const validSuggestions: string[] = [] + for (const raw of rawSuggestions) { + const normalized = tryNormalizeName(raw) + if (normalized && normalized !== name && !validSuggestions.includes(normalized)) { + validSuggestions.push(normalized) + if (validSuggestions.length >= 10) break + } + } + + return validSuggestions +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const name = searchParams.get('name') + + if (!name) { + return NextResponse.json({ error: 'Name parameter is required', suggestions: [] }, { status: 400 }) + } + + // Extract the base name (without .eth) + const baseName = name.replace(/\.eth$/i, '').toLowerCase().trim() + + if (!baseName) { + return NextResponse.json({ error: 'Invalid name', suggestions: [] }, { status: 400 }) + } + + // Get optional categories + const categoriesParam = searchParams.get('categories') + const categories = categoriesParam ? categoriesParam.split(',').map(c => c.trim()).filter(Boolean) : [] + + // Create cache key that includes categories + const cacheKey = categories.length > 0 ? `${baseName}:${categories.sort().join(',')}` : baseName + + // Check cache first + const cached = cache.get(cacheKey) + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return NextResponse.json(cached.data, { + headers: { 'X-Cache': 'HIT' }, + }) + } + + // Validate environment variable + if (!process.env.OPENAI_API_KEY) { + console.error('Missing OPENAI_API_KEY') + return NextResponse.json({ error: 'API not configured', suggestions: [] }, { status: 500 }) + } + + // Call OpenAI with name and categories + const suggestions = await callOpenAI(baseName, categories) + + const responseData: SimilarNamesResponse = { + suggestions, + } + + // Cache the result + cache.set(cacheKey, { data: responseData, timestamp: Date.now() }) + + return NextResponse.json(responseData, { + headers: { 'X-Cache': 'MISS' }, + }) + } catch (error) { + console.error('Error fetching similar names:', error) + return NextResponse.json( + { error: 'Failed to fetch similar names', suggestions: [] }, + { status: 500 } + ) + } +} + diff --git a/src/components/domains/table/components/TableRow.tsx b/src/components/domains/table/components/TableRow.tsx index 5a4749f4..8b6a33bc 100644 --- a/src/components/domains/table/components/TableRow.tsx +++ b/src/components/domains/table/components/TableRow.tsx @@ -139,6 +139,7 @@ const TableRow: React.FC = ({ address={domain.owner as `0x${string}`} className='max-w-[90%]' wrapperClassName='justify-start! max-w-full' + disableLink={true} /> )} diff --git a/src/components/home/topCategories.tsx b/src/components/home/topCategories.tsx index f42711ec..a4f33ad5 100644 --- a/src/components/home/topCategories.tsx +++ b/src/components/home/topCategories.tsx @@ -71,8 +71,15 @@ const TopCategories = () => { )) - : categories?.slice(0, 6).map((category) => ( -
+ : categories?.slice(0, 6).map((category, index) => ( +
))} diff --git a/src/components/ui/user.tsx b/src/components/ui/user.tsx index 6941b77d..c8e3b1b3 100644 --- a/src/components/ui/user.tsx +++ b/src/components/ui/user.tsx @@ -23,6 +23,7 @@ interface UserProps { avatarSize?: string fontSize?: string alignTooltip?: 'left' | 'right' + disableLink?: boolean } const User: React.FC = ({ @@ -33,6 +34,7 @@ const User: React.FC = ({ avatarSize = '18px', fontSize = '15px', alignTooltip = 'right', + disableLink = false, }) => { const { userAddress } = useUserContext() const { data: profile, isLoading: profileIsLoading } = useQuery({ @@ -62,36 +64,68 @@ const User: React.FC = ({ showDelay={750} >
- - {profile?.ens?.records?.header && ( - Header + {profile?.ens?.records?.header && ( + Header + )} + - )} - -
-

- {profile?.ens?.name ? beautifyName(profile?.ens?.name) : truncateAddress(address)} -

+
+

+ {profile?.ens?.name ? beautifyName(profile?.ens?.name) : truncateAddress(address)} +

+
- + ) : ( + + {profile?.ens?.records?.header && ( + Header + )} + +
+

+ {profile?.ens?.name ? beautifyName(profile?.ens?.name) : truncateAddress(address)} +

+
+ + )}
)