Skip to content

Commit 3b66a5e

Browse files
committed
feat: add search history on product landing page
See: FE-96
1 parent 2beec5f commit 3b66a5e

File tree

8 files changed

+143
-44
lines changed

8 files changed

+143
-44
lines changed

frontend/i18n/locales/en.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@
55
},
66
"beaconchain_homepage": "Beaconchain Homepage",
77
"common": {
8+
"action": {
9+
"try_again": "Try again"
10+
},
811
"all": "All",
912
"close": "Close",
10-
"error_retry": "An error occurred. Please try again.",
1113
"ethereum": "Ethereum",
1214
"log_in": "Log in",
1315
"no_results": "No results found.",
1416
"open_navigation": "Open Navigation",
15-
"side_navigation": "Side Navigation"
17+
"side_navigation": "Side Navigation",
18+
"something_went_wrong": "Something went wrong."
1619
},
1720
"footer": {
1821
"color_mode": {
@@ -1230,6 +1233,13 @@
12301233
},
12311234
"search": {
12321235
"filter_aria_label": "Filter by type",
1236+
"history": {
1237+
"action": {
1238+
"toggle_history": "Toggle Search History"
1239+
},
1240+
"recent": "Search History"
1241+
1242+
},
12331243
"input_label": "Search by Address / Tx hash / Block / Token / ENS",
12341244
"input_placeholder": "Search anything...",
12351245
"loading_message": "Loading search results...",

frontend/layers/base/app/components/BaseButton.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ const {
4343
:name="leadingIcon"
4444
class=""
4545
/>
46-
<span class="px-lg">
46+
<span
47+
:class="[
48+
size === 'xl' && 'px-lg',
49+
size === 'lg' && 'px-md',
50+
size === 'md' && 'px-xs',
51+
]"
52+
>
4753
<slot />
4854
</span>
4955
<LazyBaseIcon

frontend/layers/base/app/components/BaseButtonIcon.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { IconName } from '~/layers/base/app/components/BaseIcon.vue'
66
77
const {
88
size = 'md',
9+
to,
910
} = defineProps<(
1011
{
1112
// eslint-disable-next-line vue/prop-name-casing -- conditional props for props like `ariaLabel` do not work
@@ -19,17 +20,21 @@ const {
1920
}
2021
)
2122
& {
23+
isDisabled?: boolean,
2224
name: IconName,
2325
size?: 'lg' | 'md',
2426
to?: NuxtLinkProps['to'],
2527
variant: 'secondary' | 'tertiary',
2628
}
2729
>()
30+
const isButton = computed(() => !to)
2831
</script>
2932

3033
<template>
3134
<component
32-
:is="to ? NuxtLink : 'button'"
35+
:is="isButton ? 'button' : NuxtLink"
36+
:type="isButton ? 'button' : undefined"
37+
:disabled="isDisabled"
3338
:to
3439
class="border flex rounded-full bg-linear-to-b disabled:opacity-40 aria-disabled:opacity-40 active:opacity-80 size-fit"
3540
:class="[

frontend/layers/base/app/components/BaseChipGroup.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const handleSelectFilter = (value: ChipItem['value']) => {
6161

6262
<template>
6363
<ul
64-
class="flex gap-md px-2xl py-lg"
64+
class="flex gap-md"
6565
role="group"
6666
:aria-label
6767
>

frontend/layers/base/app/components/BaseSearchInput.vue

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,13 @@ const input = defineModel<string>({
3131
watchDebounced(
3232
input,
3333
async () => {
34-
if (input.value.length) {
35-
emit('search', input.value)
36-
hasSearched.value = true
37-
}
34+
emit('search', input.value)
3835
},
3936
{
4037
immediate: false,
4138
},
4239
)
4340
44-
const showDropdown = computed(() => isLoading || hasSearched.value)
4541
const groupedResults = computed(() => {
4642
if (!results?.length) return
4743
if (!groupBy) return
@@ -58,6 +54,7 @@ const handleClickOutside = (e: PointerDownOutsideEvent) => {
5854
input.value = ''
5955
hasSearched.value = false
6056
}
57+
const idSearchInput = useId()
6158
</script>
6259

6360
<template>
@@ -66,19 +63,19 @@ const handleClickOutside = (e: PointerDownOutsideEvent) => {
6663
class="base-search-input__form p-2xl isolate"
6764
>
6865
<RkComboboxRoot
69-
v-model:open="showDropdown"
66+
:open-on-focus="!!results?.length"
7067
class="relative"
7168
ignore-filter
7269
:reset-search-term-on-blur="false"
7370
>
7471
<RkLabel
75-
for="search-input"
72+
:for="idSearchInput"
7673
class="absolute bottom-2xl left-2xl dark:text-gray-400 text-sm-tight"
7774
>
7875
{{ label }}
7976
</RkLabel>
8077
<RkComboboxInput
81-
id="search-input"
78+
:id="idSearchInput"
8279
ref="search-input"
8380
v-model.trim="input"
8481
type="search"
@@ -89,13 +86,16 @@ const handleClickOutside = (e: PointerDownOutsideEvent) => {
8986
dark:focus:border-charcoal-50 dark:focus-within:outline-0"
9087
@update:model-value="(value) => { if (!value) hasSearched = false }"
9188
/>
92-
9389
<RkComboboxContent
90+
v-if="results !== undefined || isLoading || hasError"
9491
class="absolute z-10 bg-gray-50 dark:bg-gray-950 mt-xl rounded-xl w-full max-h-[400px]"
9592
@pointer-down-outside="handleClickOutside"
93+
@focus-outside.prevent
9694
>
97-
<slot name="dropdown-fixed-header" />
98-
95+
<slot
96+
name="dropdown-fixed-header"
97+
:id-search-input
98+
/>
9999
<div
100100
role="presentation"
101101
class="overflow-y-auto overscroll-contain"
@@ -113,17 +113,27 @@ const handleClickOutside = (e: PointerDownOutsideEvent) => {
113113
>
114114
<div
115115
role="alert"
116-
class="px-2xl py-md dark:text-gray-400 "
116+
class="px-2xl py-md dark:text-gray-400 flex items-center"
117117
>
118-
{{ $t('base.common.error_retry') }}
118+
<div>
119+
{{ $t('base.common.something_went_wrong') }}
120+
</div>
121+
<BaseButton
122+
trailing-icon="rotate"
123+
variant="quaternary"
124+
@click="$emit('search', input)"
125+
>
126+
{{ $t('base.common.action.try_again') }}
127+
</BaseButton>
119128
</div>
120129
</slot>
121130

122-
<slot v-else-if="!results?.length">
123-
<div class="dark:text-gray-400 px-2xl py-md font-semibold">
124-
{{ $t('base.common.no_results') }}
125-
</div>
126-
</slot>
131+
<div
132+
v-else-if="!results?.length"
133+
class="dark:text-gray-400 px-2xl py-md font-semibold"
134+
>
135+
{{ $t('base.common.no_results') }}
136+
</div>
127137

128138
<template v-else-if="results?.length && groupBy">
129139
<RkComboboxGroup
@@ -176,10 +186,6 @@ const handleClickOutside = (e: PointerDownOutsideEvent) => {
176186
</template>
177187

178188
<style lang="scss" scoped>
179-
.search-input::-webkit-search-cancel-button {
180-
display: none;
181-
}
182-
183189
form {
184190
position: relative;
185191
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useStorage } from '@vueuse/core'
2+
3+
type LocalStorageKey = 'bc-search-history-product-landing'
4+
5+
export const useLocalStorage
6+
= <T extends MaybeRefOrGetter<boolean | null | number | Record<PropertyKey, any> | string>>
7+
(key: LocalStorageKey, value: T) => {
8+
const state = useStorage<T>(key, value)
9+
return state
10+
}

frontend/layers/products/app/components/BlockchainSearchInput.vue

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const {
2020
const { t: $t } = useTranslation()
2121
2222
const emit = defineEmits<{
23-
(e: 'search'): void,
23+
(e: 'search', input: string): void,
2424
}>()
2525
2626
const searchParams = defineModel<BlockchainSearchParams>({
@@ -50,8 +50,9 @@ const chips: { label: string, value: BlockchainSearchParams['types'][number] }[]
5050
},
5151
]
5252
53-
const handleSearch = () => {
54-
emit('search')
53+
const handleSearch = (input: string) => {
54+
isHistoryVisible.value = false
55+
emit('search', input)
5556
}
5657
5758
const handleTypeFilterChange = () => {
@@ -61,29 +62,80 @@ const handleTypeFilterChange = () => {
6162
searchParams.value.types = typeFilters
6263
}
6364
64-
handleSearch()
65+
handleSearch(searchParams.value.input)
6566
}
67+
const history = useLocalStorage<string[]>('bc-search-history-product-landing', [])
68+
// using localHistory instead of history directly to avoid
69+
// that the search history in the UI is updated before navigating away
70+
const localHistory = ref<InternalPostSearchResponseWithChainId['data']>(history.value.map(item => JSON.parse(item)))
71+
const hasHistory = computed(() => !!localHistory.value.length)
72+
const hasResults = computed(() => results !== undefined)
73+
74+
const isHistoryVisible = ref<boolean>(!hasResults.value && hasHistory.value)
75+
const resultsOrHistory = computed(() => {
76+
if ((!hasResults.value && hasHistory.value) || isHistoryVisible.value) {
77+
return localHistory.value
78+
}
79+
return results
80+
})
81+
const showHistory = () => {
82+
isHistoryVisible.value = !isHistoryVisible.value
83+
localHistory.value = history.value.map(item => JSON.parse(item))
84+
}
85+
const handleClick = (searchResult: InternalPostSearchResponseWithChainId['data'][number]) => {
86+
const currentEntry = JSON.stringify(searchResult)
87+
if (history.value.length >= 10) {
88+
history.value.pop()
89+
}
90+
history.value = history.value.filter(entry => entry !== currentEntry)
91+
history.value.unshift(currentEntry)
92+
}
93+
watch(hasResults, () => {
94+
if (!hasHistory.value) return
95+
if (hasResults.value) return
96+
isHistoryVisible.value = true
97+
})
6698
</script>
6799

68100
<template>
69101
<BaseSearchInput
70102
v-model="searchParams.input"
71-
:is-loading
72-
:has-error
103+
:is-loading="isHistoryVisible ? false : isLoading"
104+
:has-error="isHistoryVisible ? false : hasError"
73105
:label="$t('products.landing_page.search.input_label')"
74106
:placeholder="$t('products.landing_page.search.input_placeholder')"
75107
:group-by="'type'"
76-
:results
108+
:results="resultsOrHistory"
77109
@search="handleSearch"
78110
>
79-
<template #dropdown-fixed-header>
80-
<BaseChipGroup
81-
v-model="searchParams.types"
82-
:items="chips"
83-
class="overflow-x-auto overscroll-contain min-h-fit"
84-
:aria-label="$t('products.landing_page.search.filter_aria_label')"
85-
@update:model-value="handleTypeFilterChange"
86-
/>
111+
<template #dropdown-fixed-header="{ idSearchInput }">
112+
<div
113+
class="min-h-fit overflow-x-auto overscroll-contain flex gap-md items-center px-2xl py-lg"
114+
@keydown.enter.stop
115+
>
116+
<BaseButtonIcon
117+
v-if="hasHistory"
118+
:aria-controls="idSearchInput"
119+
:is-disabled="!hasResults"
120+
role="switch"
121+
screenreader-text="products.landing_page.search.history.action.toggle_history"
122+
name="history"
123+
:aria-checked="`${isHistoryVisible}`"
124+
variant="secondary"
125+
@click="showHistory"
126+
/>
127+
<BaseChipGroup
128+
v-if="!isHistoryVisible"
129+
v-model="searchParams.types"
130+
:aria-controls="idSearchInput"
131+
:items="chips"
132+
:aria-label="$t('products.landing_page.search.filter_aria_label')"
133+
@update:model-value="handleTypeFilterChange"
134+
/>
135+
<span v-else>
136+
{{ $t('products.landing_page.search.history.recent') }}
137+
</span>
138+
</div>
87139
<hr class="mx-2xl text-gray-600">
88140
</template>
89141

@@ -107,7 +159,10 @@ const handleTypeFilterChange = () => {
107159
</template>
108160

109161
<template #result-item="{ result }">
110-
<BlockchainSearchResultItem :result />
162+
<BlockchainSearchResultItem
163+
:result
164+
@click="handleClick(result)"
165+
/>
111166
</template>
112167

113168
<template #loading-content>

frontend/layers/products/app/pages/products/index.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const searchParams = ref<BlockchainSearchParams>({
5454
})
5555
5656
const {
57+
clear,
5758
data,
5859
error,
5960
execute,
@@ -64,6 +65,12 @@ const {
6465
method: 'POST',
6566
watch: false,
6667
})
68+
const handleSearch = (input: string) => {
69+
if (!input.length) {
70+
return clear()
71+
}
72+
execute()
73+
}
6774
</script>
6875

6976
<template>
@@ -93,7 +100,7 @@ const {
93100
:type-filters="searchTypes"
94101
:is-loading="status === 'pending'"
95102
:has-error="!!error"
96-
@search="execute()"
103+
@search="handleSearch"
97104
/>
98105
</ProductLandingpageSection>
99106
<ProductLandingpageSection class="mt-11xl">

0 commit comments

Comments
 (0)