@meeovi/layer-search 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +181 -0
- package/app/components/README.md +3 -0
- package/app/components/atoms/BaseButton.vue +36 -0
- package/app/components/atoms/BaseCard.vue +13 -0
- package/app/components/atoms/BaseCheckbox.vue +51 -0
- package/app/components/atoms/BaseLogo.vue +19 -0
- package/app/components/atoms/BaseText.vue +17 -0
- package/app/components/atoms/BaseTitle.vue +23 -0
- package/app/components/atoms/DiscordIcon.vue +14 -0
- package/app/components/atoms/GithubIcon.vue +14 -0
- package/app/components/atoms/HalfSolidStarIcon.vue +5 -0
- package/app/components/atoms/SelectArrow.vue +5 -0
- package/app/components/atoms/SolidStarIcon.vue +5 -0
- package/app/components/atoms/StarIcon.vue +5 -0
- package/app/components/atoms/TwitterIcon.vue +14 -0
- package/app/components/atoms/WebIcon.vue +12 -0
- package/app/components/atoms/XIcon.vue +5 -0
- package/app/components/features/aiSearch.vue +0 -0
- package/app/components/features/allSearch.vue +0 -0
- package/app/components/features/autocomplete.vue +0 -0
- package/app/components/features/imageSearch.vue +0 -0
- package/app/components/features/videoSearch.vue +0 -0
- package/app/components/filters/filters.vue +0 -0
- package/app/components/molecules/BaseSelect.vue +53 -0
- package/app/components/molecules/PageNumber.vue +54 -0
- package/app/components/molecules/RangeSlider.vue +37 -0
- package/app/components/molecules/SearchInput.vue +32 -0
- package/app/components/molecules/SocialLink.vue +42 -0
- package/app/components/molecules/StarRating.vue +48 -0
- package/app/components/organisms/LoadingIndicator.vue +12 -0
- package/app/components/organisms/MeiliSearchBar.vue +15 -0
- package/app/components/organisms/MeiliSearchFacetFilter.vue +116 -0
- package/app/components/organisms/MeiliSearchLoadingProvider.vue +29 -0
- package/app/components/organisms/MeiliSearchPagination.vue +40 -0
- package/app/components/organisms/MeiliSearchProvider.vue +51 -0
- package/app/components/organisms/MeiliSearchRangeFilter.vue +52 -0
- package/app/components/organisms/MeiliSearchRatingFilter.vue +47 -0
- package/app/components/organisms/MeiliSearchResults.vue +35 -0
- package/app/components/organisms/MeiliSearchSorting.vue +23 -0
- package/app/components/organisms/MeiliSearchStats.vue +13 -0
- package/app/components/organisms/ProductCard.vue +80 -0
- package/app/components/organisms/TheNavbar.vue +71 -0
- package/app/components/results/audioSearch.vue +7 -0
- package/app/components/results/booksSearch.vue +7 -0
- package/app/components/results/financeSearch.vue +93 -0
- package/app/components/results/imageSearch.vue +7 -0
- package/app/components/results/musicSearch.vue +93 -0
- package/app/components/results/newsSearch.vue +93 -0
- package/app/components/results/spaceSearch.vue +93 -0
- package/app/components/results/spacesSearch.vue +7 -0
- package/app/components/results/travelSearch.vue +93 -0
- package/app/components/results/videoSearch.vue +7 -0
- package/app/components/search.vue +87 -0
- package/app/components/templates/HomeTemplate.vue +44 -0
- package/app/components/widgets/ClearRefinements.vue +27 -0
- package/app/components/widgets/NoResults.vue +125 -0
- package/app/components/widgets/PriceSlider.css +58 -0
- package/app/composables/adapter/meilisearch.ts +58 -0
- package/app/composables/adapter/mock.ts +34 -0
- package/app/composables/adapter/opensearch.ts +66 -0
- package/app/composables/adapter/types.ts +14 -0
- package/app/composables/bridges/instantsearch.ts +20 -0
- package/app/composables/bridges/react.ts +40 -0
- package/app/composables/bridges/vue.ts +38 -0
- package/app/composables/cli.ts +85 -0
- package/app/composables/config/schema.ts +16 -0
- package/app/composables/config.ts +20 -0
- package/app/composables/core/Facets.ts +9 -0
- package/app/composables/core/Filters.ts +13 -0
- package/app/composables/core/Normalizers.ts +0 -0
- package/app/composables/core/Pipeline.ts +20 -0
- package/app/composables/core/QueryBuilder.ts +27 -0
- package/app/composables/core/SearchContext.ts +54 -0
- package/app/composables/core/SearchManager.ts +27 -0
- package/app/composables/events.ts +6 -0
- package/app/composables/index.ts +9 -0
- package/app/composables/module.ts +72 -0
- package/app/composables/types/api/global-search.ts +8 -0
- package/app/composables/types.d.ts +12 -0
- package/app/composables/utils/normalizers.ts +6 -0
- package/app/pages/results.vue +85 -0
- package/app/plugins/instantsearch.js +35 -0
- package/app/plugins/search.js +20 -0
- package/nuxt.config.ts +11 -0
- package/package.json +43 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import GithubIcon from '~/components/atoms/GithubIcon.vue'
|
|
3
|
+
import TwitterIcon from '~/components/atoms/TwitterIcon.vue'
|
|
4
|
+
import DiscordIcon from '~/components/atoms/DiscordIcon.vue'
|
|
5
|
+
import WebIcon from '~/components/atoms/WebIcon.vue'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
url: string
|
|
9
|
+
icon: 'discord' | 'github' | 'twitter' | 'web'
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const iconComponent = computed(() => {
|
|
13
|
+
const icon = toRef(props, 'icon')
|
|
14
|
+
switch (icon.value) {
|
|
15
|
+
case 'discord':
|
|
16
|
+
return DiscordIcon
|
|
17
|
+
case 'github':
|
|
18
|
+
return GithubIcon
|
|
19
|
+
case 'twitter':
|
|
20
|
+
return TwitterIcon
|
|
21
|
+
case 'web':
|
|
22
|
+
return WebIcon
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<a :href="url" target="_blank" class="social-link">
|
|
29
|
+
<component :is="iconComponent" class="social-icon" height="36" width="36" />
|
|
30
|
+
</a>
|
|
31
|
+
</template>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
.social-link {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
color: var(--ashes-900);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.social-link:hover {
|
|
40
|
+
color: var(--dodger-blue-500);
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import SolidStarIcon from '~/components/atoms/SolidStarIcon.vue'
|
|
3
|
+
import HalfSolidStarIcon from '~/components/atoms/HalfSolidStarIcon.vue'
|
|
4
|
+
|
|
5
|
+
const props = defineProps({
|
|
6
|
+
rating: {
|
|
7
|
+
type: Number,
|
|
8
|
+
required: true,
|
|
9
|
+
validator: (value: number) => (value >= 0 && value <= 5)
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const ratingText = computed(() => `${props.rating} out of 5`)
|
|
14
|
+
|
|
15
|
+
const solidStars = computed(() => Math.floor(props.rating))
|
|
16
|
+
|
|
17
|
+
const halfStars = computed(() => {
|
|
18
|
+
// If there are 5 full stars OR if there's no decimal part
|
|
19
|
+
if (solidStars.value === 5 || props.rating % 1 === 0) {
|
|
20
|
+
return 0
|
|
21
|
+
}
|
|
22
|
+
return (props.rating % 1) >= 0.299 // JS % operator is weird
|
|
23
|
+
? 1
|
|
24
|
+
: 0
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const emptyStars = computed(() => (5 - solidStars.value - halfStars.value))
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<span :title="ratingText" class="star-rating">
|
|
32
|
+
<SolidStarIcon v-for="i in solidStars" :key="i" height="14" width="14" class="star-icon" />
|
|
33
|
+
<HalfSolidStarIcon v-if="halfStars === 1" height="14" width="14" class="star-icon" />
|
|
34
|
+
<StarIcon v-for="i in emptyStars" :key="i" height="14" width="14" class="star-icon" />
|
|
35
|
+
</span>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
.star-icon path {
|
|
40
|
+
fill: currentColor;
|
|
41
|
+
}
|
|
42
|
+
</style>
|
|
43
|
+
|
|
44
|
+
<style scoped>
|
|
45
|
+
.star-rating {
|
|
46
|
+
display: inline-flex;
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisSearchBox } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<AisSearchBox>
|
|
7
|
+
<template #default="{ currentRefinement, refine }">
|
|
8
|
+
<SearchInput
|
|
9
|
+
:value="currentRefinement"
|
|
10
|
+
@input="refine($event.currentTarget.value)"
|
|
11
|
+
@reset="refine('')"
|
|
12
|
+
/>
|
|
13
|
+
</template>
|
|
14
|
+
</AisSearchBox>
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisRefinementList } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
import pluralize from 'pluralize'
|
|
4
|
+
|
|
5
|
+
type SortingOrder = 'isRefined' | 'count' | 'name'
|
|
6
|
+
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
attribute: string
|
|
9
|
+
initialSortBy: SortingOrder
|
|
10
|
+
}>()
|
|
11
|
+
|
|
12
|
+
const searchInput = ref<string>('')
|
|
13
|
+
const sortingOrder = ref<SortingOrder>(props.initialSortBy)
|
|
14
|
+
|
|
15
|
+
const refineFacet = (searchFn: any, inputValue: string) => {
|
|
16
|
+
searchInput.value = inputValue
|
|
17
|
+
searchFn(inputValue)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sortingOptions: Array<{value: SortingOrder, label: string}> = [
|
|
21
|
+
{ value: 'count', label: 'By count' },
|
|
22
|
+
{ value: 'name', label: 'By name' }
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const sortBy = computed(() => {
|
|
26
|
+
return [
|
|
27
|
+
'isRefined',
|
|
28
|
+
sortingOrder.value
|
|
29
|
+
]
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const attributePlural = computed(() => pluralize(props.attribute))
|
|
33
|
+
|
|
34
|
+
const filterOutEmptyFacets = (items: any) => {
|
|
35
|
+
return items.filter((item: any) => item.count > 0)
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<AisRefinementList
|
|
41
|
+
:attribute="props.attribute"
|
|
42
|
+
:searchable="true"
|
|
43
|
+
:show-more="true"
|
|
44
|
+
:show-more-limit="50"
|
|
45
|
+
:sort-by="sortBy"
|
|
46
|
+
operator="or"
|
|
47
|
+
>
|
|
48
|
+
<template
|
|
49
|
+
#default="{ items, refine, searchForItems, isFromSearch, isShowingMore, canToggleShowMore, toggleShowMore }"
|
|
50
|
+
>
|
|
51
|
+
<div class="flex items-baseline">
|
|
52
|
+
<BaseTitle class="mb-3 text-valhalla-100">
|
|
53
|
+
{{ props.attribute }}
|
|
54
|
+
</BaseTitle>
|
|
55
|
+
<select v-model="sortingOrder" class="ml-auto text-ashes-900">
|
|
56
|
+
<BaseText
|
|
57
|
+
v-for="option in sortingOptions"
|
|
58
|
+
:key="option.value"
|
|
59
|
+
tag="option"
|
|
60
|
+
:value="option.value"
|
|
61
|
+
:selected="props.initialSortBy === option.value"
|
|
62
|
+
>
|
|
63
|
+
{{ option.label }}
|
|
64
|
+
</BaseText>
|
|
65
|
+
</select>
|
|
66
|
+
</div>
|
|
67
|
+
<SearchInput
|
|
68
|
+
class="mb-3"
|
|
69
|
+
:placeholder="`Search ${attributePlural}`"
|
|
70
|
+
:value="searchInput"
|
|
71
|
+
@input="refineFacet(searchForItems, $event.target.value)"
|
|
72
|
+
@reset="refineFacet(searchForItems, '')"
|
|
73
|
+
/>
|
|
74
|
+
<div class="mb-3">
|
|
75
|
+
<BaseText v-if="isFromSearch && !items.length" tag="span" size="m" class="text-ashes-900">
|
|
76
|
+
No results.
|
|
77
|
+
</BaseText>
|
|
78
|
+
<BaseCheckbox
|
|
79
|
+
v-for="item in filterOutEmptyFacets(items)"
|
|
80
|
+
:key="item.value"
|
|
81
|
+
:value="item.isRefined"
|
|
82
|
+
:label="item.label"
|
|
83
|
+
:name="item.value"
|
|
84
|
+
:disabled="item.count === 0"
|
|
85
|
+
@change="refine(item.value)"
|
|
86
|
+
>
|
|
87
|
+
<BaseText tag="span" size="m" :class="[ item.count ? 'text-valhalla-500' : 'text-ashes-900']">
|
|
88
|
+
<span v-dompurify-html="item.label" /> <BaseText tag="span" size="s" class="text-ashes-900">
|
|
89
|
+
({{ item.count.toLocaleString() }})
|
|
90
|
+
</BaseText>
|
|
91
|
+
</BaseText>
|
|
92
|
+
</BaseCheckbox>
|
|
93
|
+
</div>
|
|
94
|
+
<BaseTitle v-show="canToggleShowMore" tag="span">
|
|
95
|
+
<a
|
|
96
|
+
href="#"
|
|
97
|
+
class="link"
|
|
98
|
+
@click.prevent="toggleShowMore"
|
|
99
|
+
>
|
|
100
|
+
{{ !isShowingMore ? 'Show more' : 'Show less' }}
|
|
101
|
+
</a>
|
|
102
|
+
</BaseTitle>
|
|
103
|
+
</template>
|
|
104
|
+
</AisRefinementList>
|
|
105
|
+
</template>
|
|
106
|
+
|
|
107
|
+
<style scoped>
|
|
108
|
+
.link {
|
|
109
|
+
text-decoration: none;
|
|
110
|
+
color: var(--ashes-900);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
select {
|
|
114
|
+
border: 0;
|
|
115
|
+
}
|
|
116
|
+
</style>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div v-if="state">
|
|
3
|
+
<slot name="default" :is-search-stalled="state.searchMetadata.isSearchStalled" />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script>
|
|
8
|
+
import { createWidgetMixin } from 'vue-instantsearch/vue3/es'
|
|
9
|
+
|
|
10
|
+
const connectSearchMetaData =
|
|
11
|
+
(renderFn, unmountFn) =>
|
|
12
|
+
(widgetParams = {}) => ({
|
|
13
|
+
init () {
|
|
14
|
+
renderFn({ searchMetadata: {} }, true)
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
render ({ searchMetadata }) {
|
|
18
|
+
renderFn({ searchMetadata }, false)
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
dispose () {
|
|
22
|
+
unmountFn()
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export default {
|
|
27
|
+
mixins: [createWidgetMixin({ connector: connectSearchMetaData })]
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisPagination } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
import PageNumber from '~/components/molecules/PageNumber.vue'
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<AisPagination>
|
|
8
|
+
<template #default="{ currentRefinement, pages, refine, nbPages, isFirstPage, isLastPage }">
|
|
9
|
+
<!-- First page -->
|
|
10
|
+
<PageNumber
|
|
11
|
+
v-if="!isFirstPage && !pages.includes(0)"
|
|
12
|
+
:has-gap-separator="!pages.includes(1)"
|
|
13
|
+
:is-current="currentRefinement === 0"
|
|
14
|
+
@page-click="refine(0)"
|
|
15
|
+
>
|
|
16
|
+
Page 1
|
|
17
|
+
</PageNumber>
|
|
18
|
+
<!-- Current page and 3 previous/next -->
|
|
19
|
+
<PageNumber
|
|
20
|
+
v-for="(page, index) in pages"
|
|
21
|
+
:key="page"
|
|
22
|
+
:show-separator="index < (pages.length-1)"
|
|
23
|
+
:is-current="currentRefinement === page"
|
|
24
|
+
@page-click="refine(page)"
|
|
25
|
+
>
|
|
26
|
+
Page {{ page + 1 }}
|
|
27
|
+
</PageNumber>
|
|
28
|
+
<!-- Last page -->
|
|
29
|
+
<PageNumber
|
|
30
|
+
v-if="!isLastPage && !pages.includes(nbPages-1)"
|
|
31
|
+
separator="before"
|
|
32
|
+
:has-gap-separator="!pages.includes(nbPages-2)"
|
|
33
|
+
:is-current="currentRefinement === nbPages-1"
|
|
34
|
+
@page-click="refine(nbPages-1)"
|
|
35
|
+
>
|
|
36
|
+
Page {{ nbPages }}
|
|
37
|
+
</PageNumber>
|
|
38
|
+
</template>
|
|
39
|
+
</AisPagination>
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisInstantSearch, AisInstantSearchSsr } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
|
|
4
|
+
const props = withDefaults(defineProps<{
|
|
5
|
+
ssr?: boolean
|
|
6
|
+
indexName: string
|
|
7
|
+
initialQuery?: string
|
|
8
|
+
}>(), {
|
|
9
|
+
ssr: false,
|
|
10
|
+
initialQuery: ''
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const { ssr, indexName, initialQuery } = toRefs(props)
|
|
14
|
+
|
|
15
|
+
if (ssr) {
|
|
16
|
+
const { instantsearch } = useServerRootMixin(indexName.value)
|
|
17
|
+
|
|
18
|
+
const { data: searchResults } = await useFetch('/api/search', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
body: { query: initialQuery.value }
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// TODO: fix caveat with children components rendering using `instantsearch.findResultsState()`
|
|
24
|
+
|
|
25
|
+
instantsearch.hydrate({
|
|
26
|
+
[indexName.value]: searchResults.value
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const instantSearchComponent = computed(() => ssr.value ? AisInstantSearchSsr : AisInstantSearch)
|
|
31
|
+
|
|
32
|
+
const attributes = computed(() => {
|
|
33
|
+
if (ssr.value) {
|
|
34
|
+
return {
|
|
35
|
+
[indexName.value]: {
|
|
36
|
+
query: initialQuery.value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
indexName: indexName.value,
|
|
42
|
+
searchClient: useMeilisearch()
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<component :is="instantSearchComponent" v-bind="attributes">
|
|
49
|
+
<slot name="default" />
|
|
50
|
+
</component>
|
|
51
|
+
</template>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisRangeInput } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
|
|
4
|
+
interface Range {
|
|
5
|
+
min: number
|
|
6
|
+
max: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
attribute: string
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const { attribute } = toRefs(props)
|
|
14
|
+
|
|
15
|
+
const toValue = (currentValue: Range, boundaries: Range): [number, number] => {
|
|
16
|
+
return [
|
|
17
|
+
typeof currentValue.min === 'number' ? currentValue.min : boundaries.min,
|
|
18
|
+
typeof currentValue.max === 'number' ? currentValue.max : boundaries.max
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<AisRangeInput :attribute="attribute">
|
|
25
|
+
<template #default="{ currentRefinement, range, refine }">
|
|
26
|
+
<BaseTitle class="mb-3 text-valhalla-100">
|
|
27
|
+
{{ attribute }}
|
|
28
|
+
</BaseTitle>
|
|
29
|
+
<div class="slider-labels text-valhalla-500 mb-2">
|
|
30
|
+
<BaseText size="m">
|
|
31
|
+
<span class="text-ashes-900">$ </span>{{ currentRefinement.min ?? range.min }}
|
|
32
|
+
</BaseText>
|
|
33
|
+
<BaseText size="m">
|
|
34
|
+
<span class="text-ashes-900">$ </span>{{ currentRefinement.max ?? range.max }}
|
|
35
|
+
</BaseText>
|
|
36
|
+
</div>
|
|
37
|
+
<RangeSlider
|
|
38
|
+
:model-value="toValue(currentRefinement, range)"
|
|
39
|
+
:min="range.min"
|
|
40
|
+
:max="range.max"
|
|
41
|
+
@update:model-value="refine($event)"
|
|
42
|
+
/>
|
|
43
|
+
</template>
|
|
44
|
+
</AisRangeInput>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<style scoped>
|
|
48
|
+
.slider-labels {
|
|
49
|
+
display: flex;
|
|
50
|
+
justify-content: space-between;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisRatingMenu } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
attribute: string
|
|
6
|
+
label?: string
|
|
7
|
+
}>()
|
|
8
|
+
|
|
9
|
+
const { attribute, label } = toRefs(props)
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<AisRatingMenu
|
|
14
|
+
:attribute="attribute"
|
|
15
|
+
:max="5"
|
|
16
|
+
>
|
|
17
|
+
<template #default="{ items, refine }">
|
|
18
|
+
<BaseTitle class="mb-3 text-valhalla-100">
|
|
19
|
+
{{ label ?? attribute }}
|
|
20
|
+
</BaseTitle>
|
|
21
|
+
<a
|
|
22
|
+
v-for="item in items"
|
|
23
|
+
:key="item.value"
|
|
24
|
+
class="rating-link"
|
|
25
|
+
:class="[item.isRefined ? 'text-dodger-500' : 'text-valhalla-500']"
|
|
26
|
+
href="#"
|
|
27
|
+
@click.prevent="refine(item.value)"
|
|
28
|
+
>
|
|
29
|
+
<span class="rating-label">
|
|
30
|
+
<StarRating :rating="Number(item.value)" />
|
|
31
|
+
<BaseText
|
|
32
|
+
tag="span"
|
|
33
|
+
size="m"
|
|
34
|
+
class="ml-1"
|
|
35
|
+
>
|
|
36
|
+
& Up
|
|
37
|
+
<BaseText tag="span" size="s" class="text-ashes-900">
|
|
38
|
+
({{ item.count.toLocaleString() }})
|
|
39
|
+
</BaseText>
|
|
40
|
+
</BaseText>
|
|
41
|
+
</span>
|
|
42
|
+
</a>
|
|
43
|
+
</template>
|
|
44
|
+
</AisRatingMenu>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<style src="~/assets/css/components/rating-filter.css" scoped />
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisInfiniteHits } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<AisInfiniteHits>
|
|
7
|
+
<template
|
|
8
|
+
#default="{
|
|
9
|
+
items,
|
|
10
|
+
refineNext,
|
|
11
|
+
isLastPage,
|
|
12
|
+
}"
|
|
13
|
+
>
|
|
14
|
+
<div class="items mb-10">
|
|
15
|
+
<ProductCard
|
|
16
|
+
v-for="product in items"
|
|
17
|
+
:key="product.uniq_id"
|
|
18
|
+
:name="product.name"
|
|
19
|
+
:brand="product.brand"
|
|
20
|
+
:price="product.price"
|
|
21
|
+
:image-url="product.image_url.split('|')[0]"
|
|
22
|
+
:rating="product.rating"
|
|
23
|
+
:reviews-count="product.reviews_count"
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
<div v-if="!isLastPage">
|
|
27
|
+
<BaseButton size="large" color="dodger-blue" class="m-auto" @click="refineNext">
|
|
28
|
+
Show more results
|
|
29
|
+
</BaseButton>
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
</AisInfiniteHits>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<style src="~/assets/css/components/results-grid.css" scoped />
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisSortBy } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
options: Array<{
|
|
6
|
+
value: string
|
|
7
|
+
label: string
|
|
8
|
+
}>
|
|
9
|
+
}>()
|
|
10
|
+
|
|
11
|
+
const { options } = toRefs(props)
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<AisSortBy :items="options">
|
|
16
|
+
<template #default="{ items, refine }">
|
|
17
|
+
<BaseSelect
|
|
18
|
+
:options="items"
|
|
19
|
+
@change="refine($event.target.value)"
|
|
20
|
+
/>
|
|
21
|
+
</template>
|
|
22
|
+
</AisSortBy>
|
|
23
|
+
</template>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import { AisStats } from 'vue-instantsearch/vue3/es'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<template>
|
|
6
|
+
<ais-stats>
|
|
7
|
+
<template #default="{ nbHits, processingTimeMS, query }">
|
|
8
|
+
<BaseText size="m" class="text-valhalla-100">
|
|
9
|
+
{{ nbHits }} results found in {{ processingTimeMS }}ms
|
|
10
|
+
</BaseText>
|
|
11
|
+
</template>
|
|
12
|
+
</ais-stats>
|
|
13
|
+
</template>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
// import { TwicImg } from '@twicpics/components/vue3'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{
|
|
5
|
+
name: string
|
|
6
|
+
brand: string
|
|
7
|
+
price: number | null // some prices are null in our dataset
|
|
8
|
+
rating: number
|
|
9
|
+
reviewsCount: number
|
|
10
|
+
imageUrl: string
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const { name, brand, price, rating, reviewsCount, imageUrl } = toRefs(props)
|
|
14
|
+
|
|
15
|
+
const formattedPrice = computed(() => Number.isNaN(price) ? '-' : price)
|
|
16
|
+
|
|
17
|
+
const optimizedImageUrl = computed(() => {
|
|
18
|
+
const v = imageUrl?.value ?? ''
|
|
19
|
+
return typeof v === 'string' ? v.replace('https://images-na.ssl-images-amazon.com/images/', '/product-images/') : ''
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<BaseCard class="product-card">
|
|
25
|
+
<TwicImg
|
|
26
|
+
:alt="name"
|
|
27
|
+
:src="optimizedImageUrl"
|
|
28
|
+
class="mb-5"
|
|
29
|
+
/>
|
|
30
|
+
<div class="px-5 pb-5">
|
|
31
|
+
<BaseTitle size="xs" class="mb-1 text-hot-pink-500 -900">
|
|
32
|
+
{{ brand }}
|
|
33
|
+
</BaseTitle>
|
|
34
|
+
<BaseText
|
|
35
|
+
size="m"
|
|
36
|
+
class="mb-2 text-valhalla-500 product-name"
|
|
37
|
+
:title="name"
|
|
38
|
+
>
|
|
39
|
+
{{ name }}
|
|
40
|
+
</BaseText>
|
|
41
|
+
<BaseText size="l" class="mb-2">
|
|
42
|
+
<span class="text-ashes-900">$</span> <span class="text-valhalla-100">{{ formattedPrice }}</span>
|
|
43
|
+
</BaseText>
|
|
44
|
+
<div class="product-rating">
|
|
45
|
+
<BaseText size="s" class="mr-1 text-valhalla-100">
|
|
46
|
+
{{ rating }}
|
|
47
|
+
</BaseText>
|
|
48
|
+
<StarRating :rating="rating" class="my-auto mr-2 text-valhalla-100" />
|
|
49
|
+
<BaseText size="xs" class="text-ashes-900">
|
|
50
|
+
{{ reviewsCount }} reviews
|
|
51
|
+
</BaseText>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</BaseCard>
|
|
55
|
+
</template>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.product-card {
|
|
59
|
+
max-width: 250px;
|
|
60
|
+
transition: transform ease-in-out 150ms;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.product-card:hover {
|
|
64
|
+
box-shadow: 0px 0px 14px 8px rgba(77, 90, 125, 0.1), 0px 4px 66px rgba(77, 90, 125, 0.04);
|
|
65
|
+
transform: translateY(-5px);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.product-name {
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
text-overflow: ellipsis;
|
|
71
|
+
display: -webkit-box;
|
|
72
|
+
-webkit-line-clamp: 2;
|
|
73
|
+
-webkit-box-orient: vertical;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.product-rating {
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: baseline;
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav class="p-5 navbar">
|
|
3
|
+
<div class="mobile-nav">
|
|
4
|
+
<div class="mb-5 menu">
|
|
5
|
+
<NuxtLink to="/">
|
|
6
|
+
<BaseLogo />
|
|
7
|
+
</NuxtLink>
|
|
8
|
+
<SocialLink url="https://github.com/meilisearch/ecommerce-demo" icon="github" class="ml-auto" />
|
|
9
|
+
<SocialLink url="https://blog.meilisearch.com/nuxt-ecommerce-search-guide/?utm_campaign=ecommerce-demo&utm_source=demo" icon="web" class="ml-2" />
|
|
10
|
+
</div>
|
|
11
|
+
<div class="mr-5 mobile-search-bar">
|
|
12
|
+
<slot name="search" />
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="tablet-nav">
|
|
16
|
+
<BaseLogo class="mr-5" />
|
|
17
|
+
<div class="tablet-search-bar">
|
|
18
|
+
<slot name="search" />
|
|
19
|
+
</div>
|
|
20
|
+
<SocialLink url="https://discord.meilisearch.com/?utm_campaign=ecommerce-demo&utm_source=preview&utm_medium=navbar" icon="discord" class="mr-5" />
|
|
21
|
+
<SocialLink url="https://twitter.com/meilisearch" icon="twitter" class="mr-5" />
|
|
22
|
+
<SocialLink url="https://github.com/meilisearch/ecommerce-demo" icon="github" class="mr-5" />
|
|
23
|
+
<SocialLink url="https://blog.meilisearch.com/nuxt-ecommerce-search-guide/?utm_campaign=ecommerce-demo&utm_source=github&utm_medium=readme" icon="web" />
|
|
24
|
+
</div>
|
|
25
|
+
</nav>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<style>
|
|
29
|
+
.navbar {
|
|
30
|
+
background-color: var(--white);
|
|
31
|
+
padding-left: calc(2 * var(--size-5));
|
|
32
|
+
padding-right: calc(2 * var(--size-5));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.tablet-nav {
|
|
36
|
+
display: none;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.mobile-nav .menu {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: row;
|
|
42
|
+
align-items: center;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.mobile-search-bar {
|
|
46
|
+
width: 100%;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@media screen and (min-width: 768px) {
|
|
50
|
+
.tablet-nav {
|
|
51
|
+
display: flex;
|
|
52
|
+
flex-direction: row;
|
|
53
|
+
align-items: center;
|
|
54
|
+
}
|
|
55
|
+
.tablet-search-bar {
|
|
56
|
+
margin-left: var(--size-5);
|
|
57
|
+
margin-right: var(--size-5);
|
|
58
|
+
flex-grow: 1;
|
|
59
|
+
}
|
|
60
|
+
.mobile-nav {
|
|
61
|
+
display: none;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@media screen and (min-width: 1024px) {
|
|
66
|
+
.tablet-search-bar {
|
|
67
|
+
margin-left: calc(4 * var(--size-5));
|
|
68
|
+
margin-right: calc(4 * var(--size-5));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
</style>
|