Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c62e3cd
Add AI-powered similar names suggestions to name page
caveman-eth Jan 19, 2026
75c4998
Enhance similar names with category context and UI updates
caveman-eth Jan 19, 2026
e53ef5d
Refactor activity panel and simplify AI prompt
caveman-eth Jan 19, 2026
ec382a8
Improve similar names loading UI and AI prompt
caveman-eth Jan 19, 2026
8874e99
Add disableLink prop to User component
caveman-eth Jan 19, 2026
39a601c
Remove dynamic height logic from NamePage component
caveman-eth Jan 19, 2026
cd91529
Filter out categories not configured on frontend
caveman-eth Jan 19, 2026
a320880
Improve similar names suggestions and category filtering
caveman-eth Feb 2, 2026
46a0f18
Update card.tsx
caveman-eth Feb 2, 2026
189b0fd
Remove frontend category image checks from filtering logic
caveman-eth Feb 2, 2026
2f57c27
Bulk-fetch domains, add loadingPhase, tune AI call
caveman-eth Feb 16, 2026
4899385
Add AI-powered similar names suggestions to name page
caveman-eth Jan 19, 2026
d992917
Enhance similar names with category context and UI updates
caveman-eth Jan 19, 2026
817292f
Refactor activity panel and simplify AI prompt
caveman-eth Jan 19, 2026
c5896ca
Improve similar names loading UI and AI prompt
caveman-eth Jan 19, 2026
06c0504
Add disableLink prop to User component
caveman-eth Jan 19, 2026
d24db05
Remove dynamic height logic from NamePage component
caveman-eth Jan 19, 2026
ff86b4d
Filter out categories not configured on frontend
caveman-eth Jan 19, 2026
c98eac8
Improve similar names suggestions and category filtering
caveman-eth Feb 2, 2026
bec64c6
Update card.tsx
caveman-eth Feb 2, 2026
33cee29
Remove frontend category image checks from filtering logic
caveman-eth Feb 2, 2026
b4881ee
Bulk-fetch domains, add loadingPhase, tune AI call
caveman-eth Feb 16, 2026
0ca82cf
Update route.ts
caveman-eth Feb 16, 2026
95fdb1c
Merge branch 'ai-recommend' of https://github.com/grailsmarket/app in…
caveman-eth Feb 16, 2026
8cb8894
Add domain placeholders; strip AI numbering
caveman-eth Feb 16, 2026
152923e
Adjust UI spacing and update AI model call
caveman-eth Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/[name]/components/activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ const ActivityPanel: React.FC<Props> = ({ 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' }}
>
<h2 className='px-lg xl:px-xl font-sedan-sc text-3xl'>Activity</h2>
<div className='w-full'>
<div className='px-md w-full overflow-y-auto sm:px-0'>
<Activity
paddingBottom='0px'
activity={activity}
Expand Down
3 changes: 2 additions & 1 deletion src/app/[name]/components/name.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ import Offers from './offers'
import ActivityPanel from './activity'
import Register from './register'
import Actions from './actions'
import SimilarNames from './similarNames'
import { getRegistrationStatus } from '@/utils/getRegistrationStatus'
import { REGISTERED, UNREGISTERED } from '@/constants/domains/registrationStatuses'
import Categories from './categories'
import SecondaryDetails from './secondaryDetails'
import Metadata from './metadata'
import Roles from './roles'
// import Metadata from './metadata'

interface Props {
name: string
Expand Down Expand Up @@ -141,6 +141,7 @@ const NamePage: React.FC<Props> = ({ name }) => {
<SecondaryDetails nameDetails={nameDetails} nameDetailsIsLoading={nameDetailsIsLoading} roles={roles} />
</div>
<ActivityPanel name={name} />
<SimilarNames name={name} categories={nameDetails?.clubs} />
</div>
</div>
</div>
Expand Down
77 changes: 77 additions & 0 deletions src/app/[name]/components/similarNames.tsx
Original file line number Diff line number Diff line change
@@ -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<Props> = ({ 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 (
<div className='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'>
<div className='px-lg xl:px-xl flex items-center justify-between'>
<h2 className='font-sedan-sc text-3xl'>Recommended</h2>
<span className='text-neutral text-lg'>GrailsAI (beta)</span>
</div>

<div className='relative w-full'>
{/* Table Header */}
<div className='border-tertiary px-md md:p-md lg:p-lg hidden h-[40px] w-full flex-row items-center justify-between border-b sm:flex'>
{SIMILAR_NAMES_COLUMNS.map((column) => (
<div
key={column}
className={cn(
'text-neutral text-sm font-medium',
column === 'domain' && 'w-[35%]',
column === 'price' && 'w-[25%]',
column === 'owner' && 'w-[25%]',
column === 'actions' && 'w-[15%] text-right'
)}
>
{column === 'domain' ? 'Name' : column === 'price' ? 'Price' : column === 'owner' ? 'Owner' : ''}
</div>
))}
</div>

{/* Loading overlay - maintains same height as 20 rows */}
{isLoading ? (
<div className='flex h-[300px] w-full animate-pulse flex-col items-center justify-center gap-3'>
<LoadingCell height='20px' width='140px' radius='4px' />
<span className='text-neutral text-lg'>
{loadingPhase === 'ai' ? 'AI Thinking...' : 'Loading name data...'}
</span>
</div>
) : (
// Loaded - show domain rows
domains.slice(0, 10).map((domain, index) => (
<TableRow
key={domain.name}
domain={domain}
index={index}
displayedColumns={SIMILAR_NAMES_COLUMNS}
/>
))
)}
</div>
</div>
)
}

export default SimilarNames

145 changes: 145 additions & 0 deletions src/app/[name]/hooks/useSimilarNames.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<MarketplaceDomainType[]> {
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,
}
}

Loading