components/search/SearchSuggestion.vue
<template>
<div
class="search-suggestion-container max-sm:pb-4"
data-testid="search-suggestion-container"
@click="resetSelectedIndex"
@keydown="onKeyDown"
>
<NeoTabs
v-show="name"
v-model="activeSearchTab"
expanded
class="touch-mt-20"
nav-tabs-class="pt-6 pl-6 pr-6"
@input="resetSelectedIndex"
>
<NeoTabItem
label="Collections"
value="Collections"
data-testid="collection-tab"
item-header-class="text-left block mb-0 pb-4 px-0 pt-0"
>
<div v-if="isCollectionResultLoading">
<SearchResultItem
v-for="item in searchSuggestionEachTypeMaxNum"
:key="item"
is-loading
/>
</div>
<div
v-else-if="!collectionSuggestion.length"
class="mx-6 mt-4"
>
{{ $t('search.collectionNotFound', [name]) }}
</div>
<div v-else>
<div
v-for="(item, idx) in collectionSuggestion"
:key="item.id"
:value="item"
:class="`link-item ${idx === selectedIndex ? 'selected-item' : ''}`"
@click="gotoCollectionItem(item)"
>
<SearchResultItem :image="item.image">
<template #content>
<div class="flex flex-row justify-between pt-2 pr-2">
<span
class="font-bold max-w-[34ch] overflow-hidden text-ellipsis whitespace-nowrap"
>{{ item.name }}</span>
<span class="text-k-grey">
{{ item.chain }}
</span>
</div>
<div class="flex justify-between pr-2">
<NeoSkeleton
v-if="item.floorPrice === undefined"
:count="1"
:width="100"
:height="22"
size="medium"
active
/>
<span v-else>
{{ $t('activity.floor') }}:
<span v-if="item.floorPrice === 0"> -- </span>
<Money
v-else
:value="item.floorPrice"
:prefix="item.chain"
inline
/>
</span>
<NeoSkeleton
v-if="item.totalCount === undefined"
:count="1"
:width="100"
:height="22"
size="medium"
active
/>
<span
v-else
class="text-k-grey"
>
{{ $t('search.units') }}:
{{ item.totalCount || 0 }}
</span>
</div>
</template>
</SearchResultItem>
</div>
</div>
<nuxt-link
class="search-footer-link"
:to="{
path: `/${urlPrefix}/explore/collectibles`,
query: { ...$route.query, search: name },
}"
@click="close"
>
<div :class="loadMoreItemClassName">
{{ $t('search.seeAll') }}
<svg
class="ml-1"
width="28"
height="8"
viewBox="0 0 28 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.3536 4.35355C27.5488 4.15829 27.5488 3.84171 27.3536 3.64645L24.1716 0.464466C23.9763 0.269204 23.6597 0.269204 23.4645 0.464466C23.2692 0.659728 23.2692 0.976311 23.4645 1.17157L26.2929 4L23.4645 6.82843C23.2692 7.02369 23.2692 7.34027 23.4645 7.53553C23.6597 7.7308 23.9763 7.7308 24.1716 7.53553L27.3536 4.35355ZM0 4.5H27V3.5H0V4.5Z"
fill="currentColor"
/>
</svg>
</div>
</nuxt-link>
</NeoTabItem>
<NeoTabItem
label="NFTs"
value="NFTs"
item-header-class="text-left block mb-0 pb-4 px-0 pt-0"
data-testid="nft-tab"
>
<div v-if="isNFTResultLoading">
<SearchResultItem
v-for="item in searchSuggestionEachTypeMaxNum"
:key="item"
is-loading
/>
</div>
<div
v-else-if="!nftSuggestion.length"
class="mx-6 mt-4"
>
{{ $t('search.nftNotFound', [name]) }}
</div>
<div v-else>
<div
v-for="(item, idx) in nftSuggestion"
:key="item.id"
:value="item"
:class="`link-item ${idx === selectedIndex ? 'selected-item' : ''}`"
@click="gotoGalleryItem(item)"
>
<SearchResultItem :image="item.image">
<template #content>
<div class="flex flex-row justify-between pt-2 pr-2">
<span
class="font-bold max-w-[34ch] overflow-hidden text-ellipsis whitespace-nowrap"
>{{ item.name }}</span>
<span class="capitalize">{{ urlPrefix }}</span>
</div>
<div class="flex flex-row justify-between pr-2">
<span
class="max-w-[34ch] overflow-hidden text-ellipsis whitespace-nowrap"
>{{ item.collection?.name }}</span>
<span v-if="item.price && parseFloat(item.price) > 0">
{{ $t('price') }}:
<Money
:value="item.price"
:prefix="item.chain"
inline
/>
</span>
</div>
</template>
</SearchResultItem>
</div>
</div>
<nuxt-link
class="search-footer-link"
:to="{
path: `/${urlPrefix}/explore/items`,
query: { ...$route.query, search: name },
}"
@click="close"
>
<div :class="loadMoreItemClassName">
{{ $t('search.seeAll') }}
<svg
class="ml-1"
width="28"
height="8"
viewBox="0 0 28 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M27.3536 4.35355C27.5488 4.15829 27.5488 3.84171 27.3536 3.64645L24.1716 0.464466C23.9763 0.269204 23.6597 0.269204 23.4645 0.464466C23.2692 0.659728 23.2692 0.976311 23.4645 1.17157L26.2929 4L23.4645 6.82843C23.2692 7.02369 23.2692 7.34027 23.4645 7.53553C23.6597 7.7308 23.9763 7.7308 24.1716 7.53553L27.3536 4.35355ZM0 4.5H27V3.5H0V4.5Z"
fill="currentColor"
/>
</svg>
</div>
</nuxt-link>
</NeoTabItem>
<NeoTabItem
value="User"
label="User"
item-header-class="text-left block mb-0 pb-4 px-0 pt-0"
>
<SearchProfiles
:name="name"
@close="$emit('close')"
/>
</NeoTabItem>
</NeoTabs>
<div
v-if="!name"
class="search-history pt-5"
>
<div
v-for="item in filterSearch"
:key="item.id"
class="flex items-center justify-between mb-1 search-history-item"
@click="goToExploreResults(item)"
>
<div class="flex items-center">
<NeoIcon icon="history" />
<div class="ml-3 history-label">
{{ item.name }}
</div>
</div>
<div
class="remove-search-history flex items-center"
@click.stop.prevent="removeSearchHistory(item.name)"
>
<svg
width="12"
height="12"
viewBox="0 0 8 9"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.66644 8.0672L3.75229 5.14842L0.838143 8.0672L0.1875 7.41654L3.10623 4.50235L0.1875 1.58815L0.838143 0.9375L3.75229 3.85628L6.66644 0.942082L7.3125 1.58815L4.39835 4.50235L7.3125 7.41654L6.66644 8.0672Z"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
<NeoTabs
v-show="!name"
v-model="activeTrendingTab"
expanded
nav-tabs-class="pt-2 px-6"
content-class="px-0 py-4"
>
<NeoTabItem
label="Trending"
value="Trending"
item-header-class="text-left block mb-0 pb-4 px-0 pt-0"
>
<div
v-for="(item, idx) in defaultCollectionSuggestions"
:key="item.id"
:value="item"
:class="`link-item ${idx === selectedIndex ? 'selected-item' : ''}`"
@click="gotoCollectionItem(item)"
>
<SearchResultItem :image="item.image">
<template #content>
<div class="pr-2 pt-2">
<span
class="font-bold max-w-[34ch] overflow-hidden text-ellipsis whitespace-nowrap"
>{{ item.name }}</span>
</div>
<div class="flex flex-row justify-between pr-2 secondary-info">
<span v-if="item.nftCount">{{ $t('search.units') }}: {{ item.nftCount }}</span>
<span>{{ $t('search.owners') }}: {{ item.owners }}</span>
<span class="capitalize">{{ urlPrefix }}</span>
</div>
</template>
</SearchResultItem>
</div>
</NeoTabItem>
</NeoTabs>
</div>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import { NeoIcon, NeoSkeleton, NeoTabItem, NeoTabs } from '@kodadot1/brick'
import { useTopCollections } from '../landing/topCollections/utils/useTopCollections'
import { fetchCollectionSuggestion } from './utils/collectionSearch'
import type { DefaultCollectionSuggestion, SearchQuery } from './types'
import { getDenyList } from '@/utils/prefix'
import type { CollectionWithMeta, NFTWithMeta } from '@/types'
import { sanitizeIpfsUrl } from '@/utils/ipfs'
import { logError, mapNFTorCollectionMetadata } from '@/utils/mappers'
import { processMetadata } from '@/utils/cachingStrategy'
import resolveQueryPath from '@/utils/queryPathResolver'
import { unwrapSafe } from '@/utils/uniquery'
import Money from '@/components/shared/format/Money.vue'
const emit = defineEmits(['close', 'gotoGallery'])
const props = defineProps({
name: {
type: String,
required: true,
},
query: {
type: Object,
default: () => {
return {} as SearchQuery
},
},
showDefaultSuggestions: {
type: Boolean,
required: false,
},
})
const query = toRef(props, 'query', {})
const searchSuggestionEachTypeMaxNum = 12
const activeSearchTab = ref('Collections')
const activeTrendingTab = ref('Trending')
const selectedIndex = ref(-1)
const isCollectionResultLoading = ref(false)
const nftResult = ref([] as NFTWithMeta[])
const collectionResult = ref([] as CollectionWithMeta[])
const searched = ref([] as NFTWithMeta[])
const searchString = ref('')
const defaultCollectionSuggestions = ref<DefaultCollectionSuggestion[]>([])
onMounted(() => {
getSearchHistory()
})
const onKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowUp':
onKeydownSelected(-1)
break
case 'ArrowDown':
onKeydownSelected(+1)
break
case 'Enter':
nativeSearch()
break
}
}
const totalItemsAtCurrentTab = computed(() => {
if (!props.name) {
return defaultCollectionSuggestions.value?.length
}
return activeSearchTab.value === 'NFTs'
? nftSuggestion.value.length
: collectionSuggestion.value.length
})
const collectionSuggestion = computed(() =>
collectionResult.value.slice(0, searchSuggestionEachTypeMaxNum),
)
const nftSuggestion = computed(() =>
nftResult.value.slice(0, searchSuggestionEachTypeMaxNum),
)
const loadMoreItemClassName = computed(
() =>
`link-item${
selectedIndex.value === totalItemsAtCurrentTab.value
? ' selected-item'
: ''
}`,
)
const queryVariables = computed(() => {
return {
first: searchSuggestionEachTypeMaxNum,
offset: 0,
denyList: getDenyList(urlPrefix.value),
orderBy: query.value.sortByMultiple?.length
? query.value.sortByMultiple
: undefined,
search: buildSearchParam(),
}
})
const selectedItemListMap = computed(() => ({
Trending: defaultCollectionSuggestions,
Collections: collectionSuggestion,
NFTs: nftSuggestion,
}))
const router = useRouter()
const { $consola } = useNuxtApp()
const close = () => emit('close')
const nativeSearch = () => {
// not selected
if (selectedIndex.value === -1) {
return
}
const isSeeMore = selectedIndex.value >= totalItemsAtCurrentTab.value
// trending collection
if (!props.name) {
if (isSeeMore) {
router.push({ name: 'series-insight' })
}
else {
gotoCollectionItem(selectedItemListMap.value['Trending'][selectedIndex.value])
}
return
}
// search result
if (isSeeMore) {
emit('gotoGallery')
}
else if (activeSearchTab.value === 'NFTs') {
gotoGalleryItem(selectedItemListMap.value['NFTs'][selectedIndex.value])
}
else {
gotoCollectionItem(
selectedItemListMap.value['Collections'][selectedIndex.value],
)
}
}
const onKeydownSelected = (step: 1 | -1) => {
const total = totalItemsAtCurrentTab.value + 1
selectedIndex.value = (total + selectedIndex.value + step) % total
}
const resetSelectedIndex = () => {
selectedIndex.value = -1
}
const { urlPrefix, client } = usePrefix()
const gotoGalleryItem = (item: NFTWithMeta) => {
router.push(`/${urlPrefix.value}/gallery/${item.id}`)
emit('close', item)
}
const gotoCollectionItem = (item: CollectionWithMeta) => {
// if item is clicked when search term is there, insert to history
if (searchString.value) {
insertNewHistory()
}
const prefix = item.chain || urlPrefix.value
router.push(`/${prefix}/collection/${item.collection_id || item.id}`)
emit('close', item)
}
const buildSearchParam = (): Record<string, unknown>[] => {
const params: { name_containsInsensitive?: string, price_gt?: string }[] = []
if (query.value?.search) {
params.push({ name_containsInsensitive: query.value?.search })
}
if (query.value?.listed) {
params.push({ price_gt: '0' })
}
return params
}
const insertNewHistory = () => {
for (const s of searched.value) {
if (s.name === searchString.value) {
return
}
}
const newResult = {
type: 'History',
name: searchString.value,
} as unknown as NFTWithMeta
searched.value.push(newResult)
if (searched.value.length > 3) {
searched.value = searched.value.slice(-3)
}
localStorage.kodaDotSearchResult = JSON.stringify(searched.value)
}
const getSearchHistory = () => {
const cacheResult = localStorage.kodaDotSearchResult
if (cacheResult) {
searched.value = JSON.parse(cacheResult)
}
}
const removeSearchHistory = (value: string) => {
searched.value = searched.value.filter(r => r.name !== value)
localStorage.kodaDotSearchResult = JSON.stringify(searched.value)
}
const filterSearch = computed((): NFTWithMeta[] => {
// filter the history search which is not similar to searchString
if (!searched.value.length) {
return []
}
return searched.value.filter((option) => {
if (!option.name.trim()) {
return false
}
return (
option.name
.toString()
.toLowerCase()
.indexOf((searchString.value || '').toLowerCase()) >= 0
)
})
})
const goToExploreResults = (item) => {
emit('gotoGallery', {
search: item.name,
})
}
const getFormattedDefaultSuggestions = (
collections,
): DefaultCollectionSuggestion[] => {
return collections.map(collection => ({
id: collection.id,
name: collection.name,
image: collection.image,
nftCount: collection.nftCount,
owners: collection.ownerCount || collection.uniqueCollectors,
}))
}
const { data: topCollections, refresh: getTopCollection } = useTopCollections(
searchSuggestionEachTypeMaxNum,
props.showDefaultSuggestions,
)
watch(
topCollections,
(data) => {
defaultCollectionSuggestions.value = getFormattedDefaultSuggestions(
data,
).slice(0, searchSuggestionEachTypeMaxNum)
},
{ immediate: true },
)
watch(
() => props.showDefaultSuggestions,
(showDefaultSuggestions) => {
if (showDefaultSuggestions) {
getTopCollection()
}
},
)
const {
data: dataNFTs,
loading: isNFTResultLoading,
refetch: fetchSearchNFTs,
} = useSearchNfts({ ...queryVariables.value, immediate: false })
const updateSuggestion = useDebounceFn(async (value: string) => {
// To handle empty string
if (!value) {
// reset query-based search results once searchString is empty
collectionResult.value = []
nftResult.value = []
return
}
isCollectionResultLoading.value = true
query.value.search = value
searchString.value = value
await fetchSearchNFTs(queryVariables.value)
await updateCollectionSuggestion(value)
}, 200)
const updateNftSuggestion = async (nFTEntities) => {
try {
const nftList = unwrapSafe(
nFTEntities.slice(0, searchSuggestionEachTypeMaxNum),
)
const metadataList: string[] = nftList.map(mapNFTorCollectionMetadata)
const result: Ref<NFTWithMeta[]> = ref([])
processMetadata<NFTWithMeta>(metadataList, (meta, i) => {
result.value.push({
...nftList[i],
...meta,
image: sanitizeIpfsUrl(
meta.image || meta.animation_url || meta.mediaUri || '',
'image',
),
})
})
nftResult.value = result.value
}
catch (e) {
logError(e, msg => $consola.warn('[PREFETCH] Unable fo fetch', msg))
}
}
const updateCollectionSuggestion = async (value: string) => {
try {
const collections = await fetchCollectionSuggestion(
value,
searchSuggestionEachTypeMaxNum,
)
const metadataList: string[] = collections.map(mapNFTorCollectionMetadata)
const collectionWithImagesList = ref<CollectionWithMeta[]>([])
processMetadata<CollectionWithMeta>(metadataList, (meta, i) => {
const initialCollectionStats = {
totalCount: undefined,
floorPrice: undefined,
}
const collectionWithImages = reactive({
...collections[i],
...meta,
...initialCollectionStats, // set initial stat fields to get reactivity
image: sanitizeIpfsUrl(
collections[i].image || collections[i].mediaUri || '',
'image',
),
})
collectionWithImagesList.value.push(collectionWithImages)
fetchCollectionStats(collectionWithImages, i)
})
collectionResult.value = collectionWithImagesList.value
}
catch (e) {
logError(e, msg => $consola.warn('[PREFETCH] Unable fo fetch', msg))
}
isCollectionResultLoading.value = false
}
const fetchCollectionStats = async (
collection: CollectionWithMeta,
index: number,
) => {
const _client = collection.chain || client.value
const queryCollection = await resolveQueryPath('subsquid', 'collectionStatsById')
const { data } = await useAsyncQuery({
query: queryCollection.default,
clientId: _client,
variables: {
id: collection.collection_id,
},
})
collection.totalCount = data.value.stats.base.length
collection.floorPrice = Math.min(
...data.value.stats.listed.map(item => parseInt(item.price)),
)
if (
collectionResult.value[index]?.collection_id === collection.collection_id
) {
collectionResult.value[index] = collection
}
return collection
}
watch(dataNFTs, async (data) => {
await updateNftSuggestion(data.nFTEntities || [])
})
watch(
() => props.name,
(value) => {
updateSuggestion(value)
resetSelectedIndex()
},
)
// expose functions to parent component
defineExpose({
insertNewHistory,
})
</script>