@graphcommerce/algolia-search 7.0.0-canary.12

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/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @graphcommerce/algolia-search
2
+
3
+ ## 7.0.0-canary.12
4
+
5
+ ### Major Changes
6
+
7
+ - [#1909](https://github.com/graphcommerce-org/graphcommerce/pull/1909) [`7a1f1bb38`](https://github.com/graphcommerce-org/graphcommerce/commit/7a1f1bb382ece4167bd3816d6f2cc41ffae56710) - New Algolia search package! ([@mikekeehnen](https://github.com/mikekeehnen))
@@ -0,0 +1,72 @@
1
+ """
2
+ Type for sortable algolia options
3
+ """
4
+ input AlgoliaSortableOption {
5
+ """
6
+ The label of the index to display
7
+ """
8
+ label: String!
9
+ """
10
+ The name of the index to target.
11
+ """
12
+ value: String!
13
+ }
14
+
15
+ """
16
+ Type for conversion of Magento 2 aggregations to Algolia filterable attributes
17
+ """
18
+ input AlgoliaFilterAttribute {
19
+ """
20
+ Stores the default aggregation uid
21
+ """
22
+ aggregation: String!
23
+
24
+ """
25
+ Stores the algolia attribute that should be connected to the magento aggregation
26
+ """
27
+ toAlgoliaAttribute: String!
28
+ }
29
+
30
+ """
31
+ Type for search index config
32
+ """
33
+ input AlgoliaSearchIndexConfig {
34
+ """
35
+ Configure your Algolia Search index for Magento products
36
+ """
37
+ searchIndex: String!
38
+
39
+ """
40
+ Configures Algolia filterable attributes
41
+ """
42
+ filterAttributes: [AlgoliaFilterAttribute!]
43
+ }
44
+
45
+ extend input GraphCommerceStorefrontConfig {
46
+ """
47
+ Configure your Algolia index configurations
48
+ """
49
+ algoliaSearchIndexConfig: [AlgoliaSearchIndexConfig!]!
50
+
51
+ """
52
+ Configure the sortable attributes
53
+ """
54
+ sortOptions: [AlgoliaSortableOption!]
55
+ }
56
+
57
+ extend input GraphCommerceConfig {
58
+ """
59
+ Configure your Algolia application ID.
60
+ """
61
+ algoliaApplicationId: String!
62
+
63
+ """
64
+ Configure your Algolia Search Only API Key
65
+ """
66
+ algoliaSearchOnlyApiKey: String!
67
+
68
+ """
69
+ Configures algolia search debounce time. This will slow down the search response.
70
+ """
71
+ algoliaSearchDebounceTime: Int
72
+ }
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # Algolia Search
2
+
3
+ Implementation of Algolia Instant Search inside Graphcommerce. Add client or
4
+ server side product, category and pages search to your project!
5
+
6
+ ## Installation
7
+
8
+ 1. Find current version of your `@graphcommerce/next-ui` in your package.json.
9
+ 2. `yarn add @graphcommerce/algolia-search@1.2.3` (replace 1.2.3 with the
10
+ version of the step above)
11
+
12
+ ## Add config values to Graphcommerce configuration
13
+
14
+ This plugin contains different app and storefront configuration values.
15
+
16
+ App configuration values:
17
+
18
+ - algoliaApplicationId
19
+ - algoliaSearchOnlyApiKey
20
+ - algoliaSearchDebounceTime,
21
+
22
+ Storefront configuration values:
23
+
24
+ - algoliaSearchIndexConfig (containing a list of the following values)
25
+ - searchIndex
26
+ - filterAttributes (containing a list of the following values)
27
+ - aggregation
28
+ - toAlgoliaAttribute
29
+
30
+ ## Add server side hydration to Algolia Search
31
+
32
+ 1. Add `react-instantsearch-hooks-server` package to your project
33
+
34
+ ```
35
+ yarn add react-instantsearch-hooks-server
36
+
37
+ ```
38
+
39
+ or
40
+
41
+ ```
42
+ npm install react-instantsearch-hooks-server
43
+ ```
44
+
45
+ 2. Add the new serverState property to the `SearchResultProps` type
46
+
47
+ ```
48
+ type SearchResultProps = DefaultPageQuery &
49
+ ProductListQuery &
50
+ ProductFiltersQuery &
51
+ CategorySearchQuery & {
52
+ filterTypes: FilterTypes
53
+ params: ProductListParams
54
+ + serverState?: unknown
55
+ }
56
+ ```
57
+
58
+ 3. Add the `getServerState` method from the `react-instantsearch-hooks-server`
59
+ package to the imports of your search page
60
+
61
+ ```
62
+ ...
63
+ import { getServerState } from 'react-instantsearch-hooks-server'
64
+ ...
65
+ ```
66
+
67
+ 4. Assign the result of the `getServerState` method to the `serverState`
68
+ attribute inside of the return statement.
69
+
70
+ ```
71
+ return {
72
+ props: {
73
+ ...(await page).data,
74
+ ...(await products).data,
75
+ ...(await filters).data,
76
+ ...(await categories)?.data,
77
+ ...(await layout)?.data,
78
+ filterTypes: await filterTypes,
79
+ params: productListParams,
80
+ up: { href: '/', title: 'Home' },
81
+ apolloState: await conf.then(() => client.cache.extract()),
82
+ + serverState: await getServerState(<SearchContext />, {
83
+ + renderToString,
84
+ + }),
85
+ },
86
+ revalidate: 60 * 20,
87
+ }
88
+ ```
89
+
90
+ 5. Add the `serverState` to the `SearchContext` component.
91
+
92
+ ```
93
+ + const { products, categories, params, filters, filterTypes, serverState } = props
94
+ const search = params.url.split('/')[1]
95
+ const totalSearchResults = (categories?.items?.length ?? 0) + (products?.total_count ?? 0)
96
+ const noSearchResults = search && (!products || (products.items && products?.items?.length <= 0))
97
+
98
+ return (
99
+ <>
100
+ <PageMeta
101
+ title={
102
+ search
103
+ ? i18n._(/* i18n */ 'Results for ‘{search}’', { search })
104
+ : i18n._(/* i18n */ 'Search')
105
+ }
106
+ metaRobots={['noindex']}
107
+ canonical='/search'
108
+ />
109
+
110
+ + <SearchContext serverProps={serverState}>
111
+ ```
@@ -0,0 +1,86 @@
1
+ import { ChipMenu, extendableComponent, responsiveVal } from '@graphcommerce/next-ui'
2
+ import { SxProps, Theme } from '@mui/material'
3
+ import Box from '@mui/material/Box'
4
+ import Checkbox from '@mui/material/Checkbox'
5
+ import ListItem from '@mui/material/ListItem'
6
+ import ListItemText from '@mui/material/ListItemText'
7
+ import {
8
+ useClearRefinements,
9
+ UseClearRefinementsProps,
10
+ useRefinementList,
11
+ UseRefinementListProps,
12
+ } from 'react-instantsearch-hooks-web'
13
+
14
+ const name = 'RefinementFilterChip' as const
15
+ const parts = ['menu', 'item'] as const
16
+ const { classes } = extendableComponent(name, parts)
17
+
18
+ export interface RefinementFilterChipProps
19
+ extends Omit<UseRefinementListProps, 'transformItems'>,
20
+ Omit<UseClearRefinementsProps, 'transformItems'> {
21
+ transformItems?: UseClearRefinementsProps['transformItems']
22
+ title: string
23
+ sx?: SxProps<Theme>
24
+ }
25
+
26
+ export function RefinementFilterChip(props: RefinementFilterChipProps) {
27
+ const { title, sx, attribute, transformItems } = props
28
+ // eslint-disable-next-line @typescript-eslint/unbound-method
29
+ const { items, refine } = useRefinementList({
30
+ attribute,
31
+ })
32
+ const clearRefinementApi = useClearRefinements({
33
+ includedAttributes: [attribute],
34
+ transformItems,
35
+ })
36
+ const selectedOptions = items.filter((option) => option.isRefined).map((option) => option.label)
37
+
38
+ return (
39
+ <ChipMenu
40
+ className={classes.menu}
41
+ variant='outlined'
42
+ selected={selectedOptions.length > 0}
43
+ label={title}
44
+ selectedLabel={selectedOptions.length ? selectedOptions.join(', ') : title}
45
+ onDelete={selectedOptions.length > 0 ? () => clearRefinementApi.refine() : undefined}
46
+ sx={Array.isArray(sx) ? sx : [sx]}
47
+ >
48
+ <Box
49
+ sx={{
50
+ display: 'grid',
51
+ gridTemplateColumns: { xs: 'repeat(1, minmax(0, 1fr))', md: 'repeat(2, 1fr)' },
52
+ columnGap: responsiveVal(2, 10),
53
+ minWidth: 0,
54
+ }}
55
+ >
56
+ {items.map((option) => (
57
+ <ListItem className={classes.item} key={option?.value ?? ''} dense>
58
+ <ListItemText
59
+ onClick={() => {
60
+ refine(option?.value)
61
+ }}
62
+ >
63
+ {option?.label} <span>({option?.count})</span>
64
+ <Checkbox
65
+ edge='start'
66
+ checked={option?.isRefined}
67
+ tabIndex={-1}
68
+ size='medium'
69
+ color='primary'
70
+ disableRipple
71
+ inputProps={{ 'aria-labelledby': `filter-equal-${attribute}-${option?.value}` }}
72
+ sx={[
73
+ {
74
+ padding: 0,
75
+ margin: '0 0 0 0',
76
+ float: 'right',
77
+ },
78
+ ]}
79
+ />
80
+ </ListItemText>
81
+ </ListItem>
82
+ ))}
83
+ </Box>
84
+ </ChipMenu>
85
+ )
86
+ }
@@ -0,0 +1,79 @@
1
+ import { Money } from '@graphcommerce/magento-store'
2
+ import { ChipMenu, extendableComponent } from '@graphcommerce/next-ui'
3
+ import Box from '@mui/material/Box'
4
+ import Slider from '@mui/material/Slider'
5
+ import { useEffect, useState } from 'react'
6
+ import { useRange, UseRangeProps } from 'react-instantsearch-hooks-web'
7
+
8
+ export interface RefinementRangeChipProps extends UseRangeProps {
9
+ attribute: string
10
+ title: string
11
+ }
12
+
13
+ const { classes } = extendableComponent('RefinementRangeChip', [
14
+ 'root',
15
+ 'container',
16
+ 'slider',
17
+ ] as const)
18
+
19
+ export function RefinementRangeChip(props: RefinementRangeChipProps) {
20
+ const { attribute, title } = props
21
+ // eslint-disable-next-line @typescript-eslint/unbound-method
22
+ const { range, refine } = useRange({
23
+ attribute,
24
+ precision: 2,
25
+ })
26
+
27
+ const numberMin = Number(range.min)
28
+ const numberMax = Number(range.max)
29
+
30
+ const [value, setValue] = useState([numberMin, numberMax])
31
+
32
+ useEffect(() => {
33
+ setValue([numberMin, numberMax])
34
+ }, [numberMax, numberMin])
35
+
36
+ return (
37
+ <ChipMenu
38
+ variant='outlined'
39
+ label={title}
40
+ className={classes.root}
41
+ selected={numberMin !== value[0]}
42
+ labelRight={
43
+ <>
44
+ <Money round value={value[0] === 0 ? 0.1 : value[0]} />
45
+ {' - '}
46
+ <Money round value={value[1]} />
47
+ </>
48
+ }
49
+ onDelete={() => {
50
+ refine([undefined, undefined])
51
+ setValue([numberMin, numberMax])
52
+ }}
53
+ >
54
+ <Box
55
+ sx={(theme) => ({
56
+ padding: `${theme.spacings.xxs} ${theme.spacings.xxs} !important`,
57
+ width: '100%',
58
+ })}
59
+ className={classes.container}
60
+ >
61
+ <Slider
62
+ min={range.min}
63
+ max={range.max}
64
+ size='large'
65
+ aria-labelledby='range-slider'
66
+ value={value}
67
+ onChange={(_, newValue) => {
68
+ setValue([newValue[0], newValue[1]])
69
+ }}
70
+ onChangeCommitted={(_, newValue) => {
71
+ refine([Number(newValue[0]), Number(newValue[1])])
72
+ }}
73
+ valueLabelDisplay='off'
74
+ className={classes.slider}
75
+ />
76
+ </Box>
77
+ </ChipMenu>
78
+ )
79
+ }
@@ -0,0 +1,24 @@
1
+ import { RenderType, TypeRenderer } from '@graphcommerce/next-ui'
2
+ import React from 'react'
3
+ import { RefinementFilterChip, RefinementFilterChipProps } from './FilterChip/RefinementFilterChip'
4
+ import { RefinementRangeChip, RefinementRangeChipProps } from './FilterChip/RefinementRangeChip'
5
+ import { SortChip, SortChipProps } from './SortChip/SortChip'
6
+
7
+ interface RenderChipProps
8
+ extends RefinementFilterChipProps,
9
+ RefinementRangeChipProps,
10
+ SortChipProps {
11
+ __typename: string
12
+ }
13
+
14
+ const renderer: TypeRenderer<{ __typename: string }, Omit<RenderChipProps, '__typename'>> = {
15
+ FilterEqualTypeInput: RefinementFilterChip,
16
+ FilterRangeTypeInput: RefinementRangeChip,
17
+ FilterMatchTypeInput: () => <div>Not implemented</div>,
18
+ Sort: SortChip,
19
+ }
20
+
21
+ export function RenderChip(props: RenderChipProps) {
22
+ const { __typename, ...rest } = props
23
+ return <RenderType renderer={renderer} __typename={__typename} {...rest} />
24
+ }
@@ -0,0 +1,85 @@
1
+ import { ChipMenu, extendableComponent, responsiveVal } from '@graphcommerce/next-ui'
2
+ import { SxProps, Theme } from '@mui/material'
3
+ import Box from '@mui/material/Box'
4
+ import Checkbox from '@mui/material/Checkbox'
5
+ import ListItem from '@mui/material/ListItem'
6
+ import ListItemText from '@mui/material/ListItemText'
7
+
8
+ const name = 'SortChip' as const
9
+ const parts = ['menu', 'item'] as const
10
+ const { classes } = extendableComponent(name, parts)
11
+
12
+ export type SortByItem = {
13
+ value: string
14
+ label: string
15
+ }
16
+
17
+ export type SortByRenderState = {
18
+ initialIndex?: string
19
+ currentRefinement: string
20
+ options: SortByItem[]
21
+ refine: (value: string) => void
22
+ canRefine: boolean
23
+ }
24
+
25
+ export interface SortChipProps extends SortByRenderState {
26
+ title: string
27
+ sx?: SxProps<Theme>
28
+ }
29
+
30
+ export function SortChip(props: SortChipProps) {
31
+ const { initialIndex, currentRefinement, options, refine, canRefine, title, sx } = props
32
+
33
+ const selectedOption = options.find((option) => option.value === currentRefinement)
34
+
35
+ return (
36
+ <ChipMenu
37
+ className={classes.menu}
38
+ variant='outlined'
39
+ selected={Boolean(selectedOption)}
40
+ label={title}
41
+ selectedLabel={selectedOption ? selectedOption.label : title}
42
+ onDelete={
43
+ selectedOption ? () => canRefine && refine(initialIndex ?? options[0].value) : undefined
44
+ }
45
+ sx={Array.isArray(sx) ? sx : [sx]}
46
+ >
47
+ <Box
48
+ sx={{
49
+ display: 'grid',
50
+ gridTemplateColumns: { xs: 'repeat(1, minmax(0, 1fr))', md: 'repeat(2, 1fr)' },
51
+ columnGap: responsiveVal(2, 10),
52
+ minWidth: 0,
53
+ }}
54
+ >
55
+ {options.map((option) => (
56
+ <ListItem className={classes.item} key={option?.value ?? ''} dense>
57
+ <ListItemText
58
+ onClick={() => {
59
+ refine(option?.value)
60
+ }}
61
+ >
62
+ {option?.label}
63
+ <Checkbox
64
+ edge='start'
65
+ checked={Boolean(options.find((o) => o.value === selectedOption?.value))}
66
+ tabIndex={-1}
67
+ size='medium'
68
+ color='primary'
69
+ disableRipple
70
+ inputProps={{ 'aria-labelledby': `sort-${option?.value}` }}
71
+ sx={[
72
+ {
73
+ padding: 0,
74
+ margin: '0 0 0 0',
75
+ float: 'right',
76
+ },
77
+ ]}
78
+ />
79
+ </ListItemText>
80
+ </ListItem>
81
+ ))}
82
+ </Box>
83
+ </ChipMenu>
84
+ )
85
+ }
@@ -0,0 +1,70 @@
1
+ import { AlgoliaFilterAttribute } from '@graphcommerce/graphql-mesh'
2
+ import { FilterTypes, ProductFiltersProps } from '@graphcommerce/magento-product'
3
+ import { useMemo } from 'react'
4
+ import { useAlgoliaSearchIndexConfig } from '../../hooks/useAlgoliaSearchIndexConfig'
5
+ import { RenderChip } from '../Chip/RenderChip'
6
+
7
+ const systemFilters = [
8
+ { key: 'category_uid', algoliaKey: 'categories.level0' },
9
+ { key: 'price', algoliaKey: 'price.EUR.default' },
10
+ ]
11
+
12
+ interface FilterWithTypes extends AlgoliaFilterAttribute {
13
+ type: FilterTypes[number]
14
+ }
15
+
16
+ export function AlgoliaFilters(props: ProductFiltersProps) {
17
+ const { filterTypes, aggregations } = props
18
+
19
+ const filtersFromConfig = useAlgoliaSearchIndexConfig('_products')?.filterAttributes
20
+
21
+ const filters = useMemo(() => {
22
+ const allValues: FilterWithTypes[] = []
23
+ const filterTypesKeys = Object.keys(filterTypes)
24
+ const reducedSystemFilters = systemFilters.filter((sf) =>
25
+ filterTypesKeys.some((ftk) => sf.key === ftk),
26
+ )
27
+ const availableFilters = reducedSystemFilters.filter((rsf) =>
28
+ aggregations?.some((a) => a?.attribute_code === rsf.key),
29
+ )
30
+
31
+ // Get all items from the system filters and convert them to FilterWithTypes
32
+ availableFilters.forEach((item) => {
33
+ allValues.push({
34
+ aggregation: item.key,
35
+ toAlgoliaAttribute: item.algoliaKey,
36
+ type: filterTypes[item.key],
37
+ })
38
+ })
39
+
40
+ // Get all items from the config and convert them to FilterWithTypes
41
+ filtersFromConfig?.forEach((af) => {
42
+ allValues.push({
43
+ aggregation: af?.aggregation ?? '',
44
+ toAlgoliaAttribute: af?.toAlgoliaAttribute ?? '',
45
+ type: filterTypes[af?.aggregation ?? ''],
46
+ })
47
+ })
48
+
49
+ // Return all values that are included in the default aggregations
50
+ return allValues
51
+ }, [aggregations, filterTypes, filtersFromConfig])
52
+
53
+ if (!aggregations || !filters) return null
54
+
55
+ return (
56
+ <>
57
+ {filters.map((v) => (
58
+ <RenderChip
59
+ __typename={v.type ?? 'FilterMatchTypeInput'}
60
+ key={v.aggregation}
61
+ attribute={v.toAlgoliaAttribute}
62
+ title={
63
+ aggregations.find((a) => a?.attribute_code === v.aggregation)?.label ??
64
+ v.aggregation.charAt(0).toUpperCase() + v.aggregation.slice(1)
65
+ }
66
+ />
67
+ ))}
68
+ </>
69
+ )
70
+ }
@@ -0,0 +1,30 @@
1
+ import { ProductPaginationProps } from '@graphcommerce/magento-product'
2
+ import { Pagination } from '@graphcommerce/next-ui'
3
+ import { Box } from '@mui/material'
4
+ import { usePagination } from 'react-instantsearch-hooks-web'
5
+
6
+ export function AlgoliaPagination({
7
+ page_info,
8
+ params,
9
+ ...paginationProps
10
+ }: ProductPaginationProps) {
11
+ // eslint-disable-next-line @typescript-eslint/unbound-method
12
+ const { nbPages, refine, currentRefinement } = usePagination()
13
+
14
+ const handlePagination = (destinationPage: number) => {
15
+ refine(destinationPage - 1)
16
+ }
17
+
18
+ return (
19
+ <Pagination
20
+ count={nbPages ?? 0}
21
+ page={currentRefinement + 1}
22
+ renderLink={(page, icon, btnProps) => (
23
+ <Box {...btnProps} color='inherit' onClick={() => handlePagination(page)}>
24
+ {icon}
25
+ </Box>
26
+ )}
27
+ {...paginationProps}
28
+ />
29
+ )
30
+ }
@@ -0,0 +1,58 @@
1
+ import { Trans } from '@lingui/react'
2
+ import { Box, debounce } from '@mui/material'
3
+ import TextField from '@mui/material/TextField'
4
+ import { ChangeEvent, useCallback, useEffect, useRef } from 'react'
5
+ import { useHits, useSearchBox, UseSearchBoxProps } from 'react-instantsearch-hooks-web'
6
+
7
+ type SearchBoxProps = {
8
+ defaultValue?: string
9
+ } & UseSearchBoxProps
10
+
11
+ export function SearchBox(props: SearchBoxProps) {
12
+ const { defaultValue } = props
13
+ const searchInputElement = useRef<HTMLInputElement>(null)
14
+
15
+ const { refine } = useSearchBox()
16
+ const { results } = useHits()
17
+
18
+ // eslint-disable-next-line react-hooks/exhaustive-deps
19
+ const debounceSearch = useCallback(
20
+ debounce(
21
+ (e: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => refine(e.target.value),
22
+ import.meta.graphCommerce.algoliaSearchDebounceTime ?? 0,
23
+ ),
24
+ [refine],
25
+ )
26
+
27
+ useEffect(() => {
28
+ if (defaultValue) refine(defaultValue)
29
+ }, [defaultValue, refine])
30
+
31
+ const totalResults = results?.nbHits ?? 0
32
+
33
+ const endAdornment = (
34
+ <Box
35
+ sx={(theme) => ({
36
+ minWidth: 'max-content',
37
+ color: theme.palette.text.disabled,
38
+ paddingRight: '7px',
39
+ })}
40
+ >
41
+ {totalResults === 1 && <Trans id='{totalResults} result' values={{ totalResults }} />}
42
+ {totalResults > 1 && <Trans id='{totalResults} results' values={{ totalResults }} />}
43
+ </Box>
44
+ )
45
+
46
+ return (
47
+ <TextField
48
+ variant='outlined'
49
+ type='text'
50
+ name='search'
51
+ InputProps={{ endAdornment }}
52
+ inputRef={searchInputElement}
53
+ onChange={debounceSearch}
54
+ fullWidth
55
+ sx={{ mt: 1 }}
56
+ />
57
+ )
58
+ }
@@ -0,0 +1,25 @@
1
+ import { useHits } from 'react-instantsearch-hooks-web'
2
+ import { AlgoliaCategoryHit } from '../lib/types'
3
+
4
+ function hitToCategory(hits: AlgoliaCategoryHit[]) {
5
+ return hits.map((h) => {
6
+ const urlSplit = h.url.split('/')
7
+ const categoryUrl = urlSplit.reduce((prev, curr, currIndex) => {
8
+ if (currIndex > 2) return `${prev}/${curr}`
9
+ return ''
10
+ })
11
+ const url_key = categoryUrl.substring(0, categoryUrl.length - 5)
12
+ return {
13
+ category_uid: h.objectID,
14
+ category_level: h.level,
15
+ category_name: h.name,
16
+ category_url_path: url_key,
17
+ }
18
+ })
19
+ }
20
+
21
+ export function useAlgoliaCategoryResults() {
22
+ const { hits, results } = useHits<AlgoliaCategoryHit>()
23
+ const categories = hitToCategory(hits)
24
+ return { categories, search: results?.query }
25
+ }
@@ -0,0 +1,26 @@
1
+ import { useHits } from 'react-instantsearch-hooks-web'
2
+ import { AlgoliaPageHit } from '../lib/types'
3
+
4
+ function hitToPage(hits: AlgoliaPageHit[]) {
5
+ return hits.map((h) => {
6
+ const urlSplit = h.url.split('/')
7
+ const url = urlSplit.reduce((prev, curr, currIndex) => {
8
+ if (currIndex > 2) return `${prev}/${curr}`
9
+ return ''
10
+ })
11
+ return {
12
+ objectID: h.objectID,
13
+ name: h.name,
14
+ slug: h.slug,
15
+ url,
16
+ content: h.content,
17
+ algoliaLastUpdateAtCET: h.algoliaLastUpdateAtCET,
18
+ } satisfies AlgoliaPageHit
19
+ })
20
+ }
21
+
22
+ export function useAlgoliaPageResults() {
23
+ const { hits, results } = useHits<AlgoliaPageHit>()
24
+ const pages = hitToPage(hits)
25
+ return { pages, search: results?.query }
26
+ }
@@ -0,0 +1,59 @@
1
+ import { useQuery } from '@graphcommerce/graphql'
2
+ import { CurrencyEnum } from '@graphcommerce/graphql-mesh'
3
+ import { ProductListItemFragment, ProductListItemProps } from '@graphcommerce/magento-product'
4
+ import { StoreConfigDocument } from '@graphcommerce/magento-store'
5
+ import { useHits } from 'react-instantsearch-hooks-web'
6
+ import { AlgoliaProductHit } from '../lib/types'
7
+
8
+ function hitsToProduct(
9
+ items: AlgoliaProductHit[],
10
+ currency?: string | null,
11
+ productUrlSuffix?: string | null,
12
+ ) {
13
+ const mapHits = items.map((item) => {
14
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
15
+ const currentCurrency = (currency ?? Object.keys(item.price)[0]) as CurrencyEnum
16
+ const price = item.price[currentCurrency]
17
+ const productUrlSplit = item.url.split('/')
18
+ const productUrl = productUrlSplit[productUrlSplit.length - 1]
19
+ const url_key = productUrl.substring(0, productUrl.length - (productUrlSuffix?.length ?? 0))
20
+
21
+ return {
22
+ __typename: 'SimpleProduct',
23
+ uid: item.objectID,
24
+ small_image: {
25
+ url: item.image_url,
26
+ },
27
+ sku: item.sku,
28
+ price_range: {
29
+ minimum_price: {
30
+ final_price: {
31
+ value: price.default,
32
+ currency: currentCurrency,
33
+ },
34
+ regular_price: {
35
+ value: price.default,
36
+ currency: currentCurrency,
37
+ },
38
+ },
39
+ },
40
+ rating_summary: item.rating_summary ?? 0,
41
+ url_key,
42
+ name: item.name,
43
+ }
44
+ }) satisfies Array<ProductListItemFragment & ProductListItemProps>
45
+
46
+ return mapHits
47
+ }
48
+
49
+ export function useAlgoliaProductResults() {
50
+ const { hits } = useHits<AlgoliaProductHit>()
51
+ const { data } = useQuery(StoreConfigDocument)
52
+ const products = hitsToProduct(
53
+ hits,
54
+ data?.storeConfig?.base_currency_code,
55
+ data?.storeConfig?.product_url_suffix,
56
+ )
57
+
58
+ return { products }
59
+ }
@@ -0,0 +1,8 @@
1
+ import { storefrontConfig } from '@graphcommerce/next-ui'
2
+ import { i18n } from '@lingui/core'
3
+
4
+ export function useAlgoliaSearchIndexConfig(suffix: string) {
5
+ return storefrontConfig(i18n.locale)?.algoliaSearchIndexConfig.find((ai) =>
6
+ ai.searchIndex.includes(suffix),
7
+ )
8
+ }
@@ -0,0 +1,6 @@
1
+ import { useRouter } from 'next/router'
2
+
3
+ export function useSearchRoute() {
4
+ const router = useRouter()
5
+ return router.asPath.includes('/search')
6
+ }
package/index.ts ADDED
File without changes
@@ -0,0 +1,2 @@
1
+ export const applicationId = import.meta.graphCommerce.algoliaApplicationId ?? ''
2
+ export const searchOnlyApiKey = import.meta.graphCommerce.algoliaSearchOnlyApiKey ?? ''
package/lib/types.ts ADDED
@@ -0,0 +1,58 @@
1
+ export type AlgoliaProductHit = {
2
+ algoliaLastUpdateAtCET: string
3
+ categories: { [key: string]: string[] }
4
+ categories_without_path: string[]
5
+ categoryIds: string[]
6
+ created_at: string
7
+ image_url: string
8
+ name: string
9
+ objectID: string
10
+ price: {
11
+ [key: string]: {
12
+ default: number
13
+ default_formated: string
14
+ default_original_formated: string
15
+ special_from_date: number
16
+ special_to_date: string
17
+ }
18
+ }
19
+ price_with_tax: {
20
+ [key: string]: {
21
+ default: number
22
+ default_formated: string
23
+ default_original_formated: string
24
+ special_from_date: number
25
+ special_to_date: string
26
+ }
27
+ }
28
+ rating_summary: number | null
29
+ sku: string
30
+ thumbnail_url: string
31
+ type_id: string
32
+ url: string
33
+ visibility_catalog: number
34
+ visibility_search: number
35
+ __position: number
36
+ }
37
+
38
+ export type AlgoliaCategoryHit = {
39
+ objectID: string
40
+ name: string
41
+ path: string
42
+ product_count: number
43
+ level: number
44
+ url: string
45
+ include_in_menu: number
46
+ _tags: string[]
47
+ popularity: number
48
+ algoliaLastUpdateAtCET: string | Date
49
+ }
50
+
51
+ export type AlgoliaPageHit = {
52
+ objectID: string
53
+ name: string
54
+ slug: string
55
+ url: string
56
+ content: string
57
+ algoliaLastUpdateAtCET: string
58
+ }
package/next-env.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/types/global" />
3
+ /// <reference types="next/image-types/global" />
4
+ /// <reference types="@graphcommerce/next-ui/types" />
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@graphcommerce/algolia-search",
3
+ "homepage": "https://www.graphcommerce.org/",
4
+ "repository": "github:graphcommerce-org/graphcommerce",
5
+ "version": "7.0.0-canary.12",
6
+ "sideEffects": false,
7
+ "prettier": "@graphcommerce/prettier-config-pwa",
8
+ "eslintConfig": {
9
+ "extends": "@graphcommerce/eslint-config-pwa",
10
+ "parserOptions": {
11
+ "project": "./tsconfig.json"
12
+ }
13
+ },
14
+ "devDependencies": {
15
+ "@graphcommerce/eslint-config-pwa": "7.0.0-canary.12",
16
+ "@graphcommerce/next-config": "^7.0.0-canary.12",
17
+ "@graphcommerce/prettier-config-pwa": "7.0.0-canary.12",
18
+ "@graphcommerce/typescript-config-pwa": "7.0.0-canary.12"
19
+ },
20
+ "dependencies": {
21
+ "@graphcommerce/graphql": "7.0.0-canary.12",
22
+ "@graphcommerce/ecommerce-ui": "7.0.0-canary.12",
23
+ "@graphcommerce/magento-search": "7.0.0-canary.12",
24
+ "@graphcommerce/next-config": "^7.0.0-canary.12",
25
+ "@graphcommerce/next-ui": "7.0.0-canary.12",
26
+ "@graphcommerce/magento-product": "7.0.0-canary.12",
27
+ "@graphcommerce/graphql-mesh": "7.0.0-canary.12",
28
+ "@graphcommerce/magento-store": "7.0.0-canary.12",
29
+ "algoliasearch": "^4.15.0",
30
+ "react-instantsearch-hooks-web": "^6.41.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@lingui/core": "^3.13.2",
34
+ "@lingui/react": "^3.13.2",
35
+ "@mui/material": "^5.10.16",
36
+ "next": "^13.2.0",
37
+ "react": "^18.2.0",
38
+ "react-dom": "^18.2.0"
39
+ }
40
+ }
@@ -0,0 +1,45 @@
1
+ import { CategorySearchResult, SearchFormProps } from '@graphcommerce/magento-search'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { Index } from 'react-instantsearch-hooks-web'
4
+ import { useAlgoliaCategoryResults } from '../hooks/useAlgoliaCategoryResults'
5
+ import { useAlgoliaSearchIndexConfig } from '../hooks/useAlgoliaSearchIndexConfig'
6
+
7
+ export const component = 'SearchForm'
8
+ export const exported = '@graphcommerce/magento-search'
9
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
10
+
11
+ function CategoryHits() {
12
+ const { categories, search } = useAlgoliaCategoryResults()
13
+
14
+ if (!search || search.length <= 0) return null
15
+
16
+ return (
17
+ <>
18
+ {categories.map((category) => (
19
+ <CategorySearchResult
20
+ breadcrumbs={[category]}
21
+ search={search}
22
+ url_path={category.category_url_path}
23
+ />
24
+ ))}
25
+ </>
26
+ )
27
+ }
28
+
29
+ function AlgoliaCategorySearchPlugin(props: PluginProps<SearchFormProps>) {
30
+ const { Prev, ...rest } = props
31
+ const searchIndex = useAlgoliaSearchIndexConfig('_categories')?.searchIndex
32
+
33
+ if (!searchIndex) return <Prev {...rest} />
34
+
35
+ return (
36
+ <>
37
+ <Prev {...rest} />
38
+ <Index indexName={searchIndex}>
39
+ <CategoryHits />
40
+ </Index>
41
+ </>
42
+ )
43
+ }
44
+
45
+ export const Plugin = AlgoliaCategorySearchPlugin
@@ -0,0 +1,14 @@
1
+ import { ProductFiltersProps } from '@graphcommerce/magento-product'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { AlgoliaFilters } from '../components/Filters/AlgoliaFilters'
4
+
5
+ export const component = 'ProductListFiltersSearch'
6
+ export const exported = '@graphcommerce/magento-search'
7
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
8
+
9
+ function AlgoliaFiltersPlugin(props: PluginProps<ProductFiltersProps>) {
10
+ const { Prev, ...rest } = props
11
+ return <AlgoliaFilters {...rest} />
12
+ }
13
+
14
+ export const Plugin = AlgoliaFiltersPlugin
@@ -0,0 +1,52 @@
1
+ import { CategorySearchResult, SearchFormProps } from '@graphcommerce/magento-search'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { Index } from 'react-instantsearch-hooks-web'
4
+ import { useAlgoliaPageResults } from '../hooks/useAlgoliaPageResults'
5
+ import { useAlgoliaSearchIndexConfig } from '../hooks/useAlgoliaSearchIndexConfig'
6
+
7
+ export const component = 'SearchForm'
8
+ export const exported = '@graphcommerce/magento-search'
9
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
10
+
11
+ function PageHits() {
12
+ const { pages, search } = useAlgoliaPageResults()
13
+
14
+ if (!search || search.length <= 0) return null
15
+
16
+ return (
17
+ <>
18
+ {pages.map((page) => (
19
+ <CategorySearchResult
20
+ breadcrumbs={[
21
+ {
22
+ category_uid: page.objectID,
23
+ category_level: page.url.split('/').length,
24
+ category_name: page.name,
25
+ category_url_path: page.url,
26
+ },
27
+ ]}
28
+ search={search}
29
+ url_path={page.url}
30
+ />
31
+ ))}
32
+ </>
33
+ )
34
+ }
35
+
36
+ function AlgoliaPageSearchPlugin(props: PluginProps<SearchFormProps>) {
37
+ const { Prev, ...rest } = props
38
+ const searchIndex = useAlgoliaSearchIndexConfig('_pages')?.searchIndex
39
+
40
+ if (!searchIndex) return <Prev {...rest} />
41
+
42
+ return (
43
+ <>
44
+ <Prev {...rest} />
45
+ <Index indexName={searchIndex}>
46
+ <PageHits />
47
+ </Index>
48
+ </>
49
+ )
50
+ }
51
+
52
+ export const Plugin = AlgoliaPageSearchPlugin
@@ -0,0 +1,18 @@
1
+ import { ProductPaginationProps } from '@graphcommerce/magento-product'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { useRouter } from 'next/router'
4
+ import { AlgoliaPagination } from '../components/Pagination/AlgoliaPagination'
5
+
6
+ export const component = 'ProductListPaginationSearch'
7
+ export const exported = '@graphcommerce/magento-search'
8
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
9
+
10
+ function AlgoliaPaginationPlugin(props: PluginProps<ProductPaginationProps>) {
11
+ const { Prev, ...rest } = props
12
+ const router = useRouter()
13
+ if (!router.asPath.includes('/search')) return <Prev {...props} />
14
+
15
+ return <AlgoliaPagination {...rest} />
16
+ }
17
+
18
+ export const Plugin = AlgoliaPaginationPlugin
@@ -0,0 +1,24 @@
1
+ import { ProductCountProps } from '@graphcommerce/magento-product'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { Index, usePagination } from 'react-instantsearch-hooks-web'
4
+ import { useAlgoliaSearchIndexConfig } from '../hooks/useAlgoliaSearchIndexConfig'
5
+
6
+ export const component = 'ProductListCountSearch'
7
+ export const exported = '@graphcommerce/magento-search'
8
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
9
+
10
+ function AlgoliaProductListCountPlugin(props: PluginProps<ProductCountProps>) {
11
+ const { Prev, ...rest } = props
12
+ const { nbHits } = usePagination()
13
+ const searchIndex = useAlgoliaSearchIndexConfig('_products')?.searchIndex
14
+
15
+ if (!searchIndex) return <Prev {...rest} />
16
+
17
+ return (
18
+ <Index indexName={searchIndex}>
19
+ <Prev {...rest} total_count={nbHits} />
20
+ </Index>
21
+ )
22
+ }
23
+
24
+ export const Plugin = AlgoliaProductListCountPlugin
@@ -0,0 +1,26 @@
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+ import { ProductItemsGridProps } from '@graphcommerce/magento-product'
3
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
4
+ import { Index } from 'react-instantsearch-hooks-web'
5
+ import { useAlgoliaProductResults } from '../hooks/useAlgoliaProductResults'
6
+ import { useAlgoliaSearchIndexConfig } from '../hooks/useAlgoliaSearchIndexConfig'
7
+
8
+ export const component = 'ProductListItemsSearch'
9
+ export const exported = '@graphcommerce/magento-search'
10
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
11
+
12
+ function AlgoliaProductSearchPlugin(props: PluginProps<ProductItemsGridProps>) {
13
+ const { Prev, ...rest } = props
14
+ const { products } = useAlgoliaProductResults()
15
+ const searchIndex = useAlgoliaSearchIndexConfig('_products')?.searchIndex
16
+
17
+ if (!searchIndex) return <Prev {...rest} />
18
+
19
+ return (
20
+ <Index indexName={searchIndex}>
21
+ <Prev {...rest} items={products} />
22
+ </Index>
23
+ )
24
+ }
25
+
26
+ export const Plugin = AlgoliaProductSearchPlugin
@@ -0,0 +1,28 @@
1
+ import { ProductFiltersProps } from '@graphcommerce/magento-product'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { storefrontConfig } from '@graphcommerce/next-ui'
4
+ import { i18n } from '@lingui/core'
5
+ import { useSortBy } from 'react-instantsearch-hooks-web'
6
+ import { RenderChip } from '../components/Chip/RenderChip'
7
+
8
+ export const component = 'ProductListSortSearch'
9
+ export const exported = '@graphcommerce/magento-search'
10
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
11
+
12
+ function AlgoliaProductSortPlugin(props: PluginProps<ProductFiltersProps>) {
13
+ const { Prev, ...rest } = props
14
+ const sort = useSortBy({
15
+ items: storefrontConfig(i18n.locale)?.sortOptions ?? [],
16
+ })
17
+
18
+ return (
19
+ <RenderChip
20
+ __typename='Sort'
21
+ title={rest.title ?? i18n._(/* i18n */ 'Sort')}
22
+ attribute='sort'
23
+ {...sort}
24
+ />
25
+ )
26
+ }
27
+
28
+ export const Plugin = AlgoliaProductSortPlugin
@@ -0,0 +1,32 @@
1
+ import { SearchContextProps } from '@graphcommerce/magento-search'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import algoliasearch from 'algoliasearch/lite'
4
+ import { InstantSearch, InstantSearchSSRProvider } from 'react-instantsearch-hooks-web'
5
+ import { useAlgoliaSearchIndexConfig } from '../hooks/useAlgoliaSearchIndexConfig'
6
+ import { applicationId, searchOnlyApiKey } from '../lib/configuration'
7
+
8
+ export const component = 'SearchContext'
9
+ export const exported = '@graphcommerce/magento-search'
10
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
11
+
12
+ const searchClient = algoliasearch(applicationId, searchOnlyApiKey)
13
+
14
+ function AlgoliaSearchContextPlugin(props: PluginProps<SearchContextProps>) {
15
+ const { Prev, serverProps, ...rest } = props
16
+ const searchIndex = useAlgoliaSearchIndexConfig('_products')?.searchIndex
17
+
18
+ if (!searchIndex)
19
+ throw Error(
20
+ '(@graphcommerce/algolia-plugin): No search index with "_products" suffix provided. Please add the search index to the Graphcommerce config',
21
+ )
22
+
23
+ return (
24
+ <InstantSearchSSRProvider {...(typeof serverProps === 'object' ? serverProps : {})}>
25
+ <InstantSearch searchClient={searchClient} indexName={searchIndex}>
26
+ <Prev {...rest} />
27
+ </InstantSearch>
28
+ </InstantSearchSSRProvider>
29
+ )
30
+ }
31
+
32
+ export const Plugin = AlgoliaSearchContextPlugin
@@ -0,0 +1,14 @@
1
+ import { SearchFormProps } from '@graphcommerce/magento-search'
2
+ import { IfConfig, PluginProps } from '@graphcommerce/next-config'
3
+ import { SearchBox } from '../components/SearchBox/SearchBox'
4
+
5
+ export const component = 'SearchForm'
6
+ export const exported = '@graphcommerce/magento-search'
7
+ export const ifConfig: IfConfig = 'algoliaApplicationId'
8
+
9
+ function AlgoliaSearchFieldPlugin(props: PluginProps<SearchFormProps>) {
10
+ const { search } = props
11
+ return <SearchBox defaultValue={search} />
12
+ }
13
+
14
+ export const Plugin = AlgoliaSearchFieldPlugin
package/tsconfig.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "exclude": ["**/node_modules", "**/.*/"],
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "extends": "@graphcommerce/typescript-config-pwa/nextjs.json"
5
+ }