@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.
Files changed (86) hide show
  1. package/README.md +181 -0
  2. package/app/components/README.md +3 -0
  3. package/app/components/atoms/BaseButton.vue +36 -0
  4. package/app/components/atoms/BaseCard.vue +13 -0
  5. package/app/components/atoms/BaseCheckbox.vue +51 -0
  6. package/app/components/atoms/BaseLogo.vue +19 -0
  7. package/app/components/atoms/BaseText.vue +17 -0
  8. package/app/components/atoms/BaseTitle.vue +23 -0
  9. package/app/components/atoms/DiscordIcon.vue +14 -0
  10. package/app/components/atoms/GithubIcon.vue +14 -0
  11. package/app/components/atoms/HalfSolidStarIcon.vue +5 -0
  12. package/app/components/atoms/SelectArrow.vue +5 -0
  13. package/app/components/atoms/SolidStarIcon.vue +5 -0
  14. package/app/components/atoms/StarIcon.vue +5 -0
  15. package/app/components/atoms/TwitterIcon.vue +14 -0
  16. package/app/components/atoms/WebIcon.vue +12 -0
  17. package/app/components/atoms/XIcon.vue +5 -0
  18. package/app/components/features/aiSearch.vue +0 -0
  19. package/app/components/features/allSearch.vue +0 -0
  20. package/app/components/features/autocomplete.vue +0 -0
  21. package/app/components/features/imageSearch.vue +0 -0
  22. package/app/components/features/videoSearch.vue +0 -0
  23. package/app/components/filters/filters.vue +0 -0
  24. package/app/components/molecules/BaseSelect.vue +53 -0
  25. package/app/components/molecules/PageNumber.vue +54 -0
  26. package/app/components/molecules/RangeSlider.vue +37 -0
  27. package/app/components/molecules/SearchInput.vue +32 -0
  28. package/app/components/molecules/SocialLink.vue +42 -0
  29. package/app/components/molecules/StarRating.vue +48 -0
  30. package/app/components/organisms/LoadingIndicator.vue +12 -0
  31. package/app/components/organisms/MeiliSearchBar.vue +15 -0
  32. package/app/components/organisms/MeiliSearchFacetFilter.vue +116 -0
  33. package/app/components/organisms/MeiliSearchLoadingProvider.vue +29 -0
  34. package/app/components/organisms/MeiliSearchPagination.vue +40 -0
  35. package/app/components/organisms/MeiliSearchProvider.vue +51 -0
  36. package/app/components/organisms/MeiliSearchRangeFilter.vue +52 -0
  37. package/app/components/organisms/MeiliSearchRatingFilter.vue +47 -0
  38. package/app/components/organisms/MeiliSearchResults.vue +35 -0
  39. package/app/components/organisms/MeiliSearchSorting.vue +23 -0
  40. package/app/components/organisms/MeiliSearchStats.vue +13 -0
  41. package/app/components/organisms/ProductCard.vue +80 -0
  42. package/app/components/organisms/TheNavbar.vue +71 -0
  43. package/app/components/results/audioSearch.vue +7 -0
  44. package/app/components/results/booksSearch.vue +7 -0
  45. package/app/components/results/financeSearch.vue +93 -0
  46. package/app/components/results/imageSearch.vue +7 -0
  47. package/app/components/results/musicSearch.vue +93 -0
  48. package/app/components/results/newsSearch.vue +93 -0
  49. package/app/components/results/spaceSearch.vue +93 -0
  50. package/app/components/results/spacesSearch.vue +7 -0
  51. package/app/components/results/travelSearch.vue +93 -0
  52. package/app/components/results/videoSearch.vue +7 -0
  53. package/app/components/search.vue +87 -0
  54. package/app/components/templates/HomeTemplate.vue +44 -0
  55. package/app/components/widgets/ClearRefinements.vue +27 -0
  56. package/app/components/widgets/NoResults.vue +125 -0
  57. package/app/components/widgets/PriceSlider.css +58 -0
  58. package/app/composables/adapter/meilisearch.ts +58 -0
  59. package/app/composables/adapter/mock.ts +34 -0
  60. package/app/composables/adapter/opensearch.ts +66 -0
  61. package/app/composables/adapter/types.ts +14 -0
  62. package/app/composables/bridges/instantsearch.ts +20 -0
  63. package/app/composables/bridges/react.ts +40 -0
  64. package/app/composables/bridges/vue.ts +38 -0
  65. package/app/composables/cli.ts +85 -0
  66. package/app/composables/config/schema.ts +16 -0
  67. package/app/composables/config.ts +20 -0
  68. package/app/composables/core/Facets.ts +9 -0
  69. package/app/composables/core/Filters.ts +13 -0
  70. package/app/composables/core/Normalizers.ts +0 -0
  71. package/app/composables/core/Pipeline.ts +20 -0
  72. package/app/composables/core/QueryBuilder.ts +27 -0
  73. package/app/composables/core/SearchContext.ts +54 -0
  74. package/app/composables/core/SearchManager.ts +27 -0
  75. package/app/composables/events.ts +6 -0
  76. package/app/composables/index.ts +9 -0
  77. package/app/composables/module.ts +72 -0
  78. package/app/composables/types/api/global-search.ts +8 -0
  79. package/app/composables/types.d.ts +12 -0
  80. package/app/composables/utils/normalizers.ts +6 -0
  81. package/app/pages/results.vue +85 -0
  82. package/app/plugins/instantsearch.js +35 -0
  83. package/app/plugins/search.js +20 -0
  84. package/nuxt.config.ts +11 -0
  85. package/package.json +43 -0
  86. 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,12 @@
1
+ <script lang="ts" setup>
2
+ import { HollowDotsSpinner } from 'epic-spinners'
3
+ </script>
4
+
5
+ <template>
6
+ <HollowDotsSpinner
7
+ :animation-duration="1000"
8
+ :dot-size="15"
9
+ :dots-num="3"
10
+ color="#FF5CAA"
11
+ />
12
+ </template>
@@ -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>