@graphcommerce/algolia-products 9.0.0-canary.100
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 +105 -0
- package/Config.graphqls +54 -0
- package/README.md +79 -0
- package/algolia-spec.yaml +4418 -0
- package/graphql/CustomerGroupId.graphql +3 -0
- package/graphql/GetAlgoliaSettings.graphql +122 -0
- package/graphql/ProductListItems_Algolia.graphql +3 -0
- package/hooks/useAlgoliaIndexName.ts +5 -0
- package/hooks/useAlgoliaQueryContext.ts +11 -0
- package/index.ts +8 -0
- package/link/customerGroupIdLink.ts +20 -0
- package/mesh/algoliaFacetsToAggregations.ts +209 -0
- package/mesh/algoliaHitToMagentoProduct.ts +201 -0
- package/mesh/getAlgoliaSettings.ts +21 -0
- package/mesh/getAttributeList.ts +31 -0
- package/mesh/getGroupId.ts +7 -0
- package/mesh/getIndexName.ts +11 -0
- package/mesh/getSearchResults.ts +35 -0
- package/mesh/getSearchResultsInput.ts +30 -0
- package/mesh/getSearchSuggestions.ts +27 -0
- package/mesh/getSearchSuggestionsInput.ts +21 -0
- package/mesh/getStoreConfig.ts +33 -0
- package/mesh/productFilterInputToAlgoliafacetFiltersInput.ts +81 -0
- package/mesh/resolvers.ts +126 -0
- package/mesh/sortOptions.ts +76 -0
- package/mesh/utils.ts +3 -0
- package/next-env.d.ts +4 -0
- package/package.json +32 -0
- package/plugins/GraphQLProviderAlgoliaCustomerGroupId.tsx +14 -0
- package/plugins/ProductListItemsBaseAlgolia.tsx +21 -0
- package/plugins/magentoProductApplyAlgoliaEngine.ts +25 -0
- package/plugins/magentoSearchApplyAlgoliaEngine.ts +26 -0
- package/plugins/meshConfigAlgolia.ts +66 -0
- package/schema/AlgoliaSchema.graphqls +60 -0
- package/schema/CustomerAlgoliaGroupId.graphqls +9 -0
- package/scripts/base-schema-filter.mts +45 -0
- package/scripts/generate-spec.mts +69 -0
- package/tsconfig.json +5 -0
- package/utils/applyCategoryEngineVariable.ts +11 -0
- package/utils/applyEngineVariable.ts +11 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
query GetAlgoliaSettings($indexName: String!) {
|
|
2
|
+
algolia_getSettings(indexName: $indexName) {
|
|
3
|
+
attributesForFaceting
|
|
4
|
+
replicas
|
|
5
|
+
paginationLimitedTo
|
|
6
|
+
unretrievableAttributes
|
|
7
|
+
disableTypoToleranceOnWords
|
|
8
|
+
attributesToTransliterate
|
|
9
|
+
camelCaseAttributes
|
|
10
|
+
decompoundedAttributes {
|
|
11
|
+
de
|
|
12
|
+
}
|
|
13
|
+
indexLanguages
|
|
14
|
+
disablePrefixOnAttributes
|
|
15
|
+
allowCompressionOfIntegerArray
|
|
16
|
+
numericAttributesForFiltering
|
|
17
|
+
separatorsToIndex
|
|
18
|
+
searchableAttributes
|
|
19
|
+
userData {
|
|
20
|
+
settingID
|
|
21
|
+
pluginVersion
|
|
22
|
+
}
|
|
23
|
+
customNormalization
|
|
24
|
+
attributeForDistinct
|
|
25
|
+
attributesToRetrieve
|
|
26
|
+
ranking
|
|
27
|
+
customRanking
|
|
28
|
+
relevancyStrictness
|
|
29
|
+
attributesToHighlight
|
|
30
|
+
attributesToSnippet
|
|
31
|
+
highlightPreTag
|
|
32
|
+
highlightPostTag
|
|
33
|
+
snippetEllipsisText
|
|
34
|
+
restrictHighlightAndSnippetArrays
|
|
35
|
+
hitsPerPage
|
|
36
|
+
minWordSizefor1Typo
|
|
37
|
+
minWordSizefor2Typos
|
|
38
|
+
typoTolerance {
|
|
39
|
+
__typename
|
|
40
|
+
... on AlgoliaBoolean_container {
|
|
41
|
+
Boolean
|
|
42
|
+
}
|
|
43
|
+
... on Algoliatypo_tolerance_container {
|
|
44
|
+
typo_tolerance
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
allowTyposOnNumericTokens
|
|
48
|
+
disableTypoToleranceOnAttributes
|
|
49
|
+
ignorePlurals {
|
|
50
|
+
__typename
|
|
51
|
+
... on AlgoliasupportedLanguage_container {
|
|
52
|
+
supportedLanguage
|
|
53
|
+
}
|
|
54
|
+
... on AlgoliaBoolean_container {
|
|
55
|
+
Boolean
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
removeStopWords {
|
|
59
|
+
__typename
|
|
60
|
+
}
|
|
61
|
+
keepDiacriticsOnCharacters
|
|
62
|
+
queryLanguages
|
|
63
|
+
decompoundQuery
|
|
64
|
+
enableRules
|
|
65
|
+
enablePersonalization
|
|
66
|
+
queryType
|
|
67
|
+
removeWordsIfNoResults
|
|
68
|
+
mode
|
|
69
|
+
semanticSearch {
|
|
70
|
+
eventSources {
|
|
71
|
+
__typename
|
|
72
|
+
... on AlgoliaString_container {
|
|
73
|
+
String
|
|
74
|
+
}
|
|
75
|
+
... on AlgoliaVoid_container {
|
|
76
|
+
Void
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
advancedSyntax
|
|
81
|
+
optionalWords
|
|
82
|
+
disableExactOnAttributes
|
|
83
|
+
exactOnSingleWordQuery
|
|
84
|
+
alternativesAsExact
|
|
85
|
+
advancedSyntaxFeatures
|
|
86
|
+
distinct
|
|
87
|
+
replaceSynonymsInHighlight
|
|
88
|
+
minProximity
|
|
89
|
+
responseFields
|
|
90
|
+
maxFacetHits
|
|
91
|
+
maxValuesPerFacet
|
|
92
|
+
sortFacetValuesBy
|
|
93
|
+
attributeCriteriaComputedByMinProximity
|
|
94
|
+
renderingContent {
|
|
95
|
+
facetOrdering {
|
|
96
|
+
facets {
|
|
97
|
+
order
|
|
98
|
+
}
|
|
99
|
+
values {
|
|
100
|
+
additionalProperties {
|
|
101
|
+
key
|
|
102
|
+
value {
|
|
103
|
+
order
|
|
104
|
+
sortRemainingBy
|
|
105
|
+
hide
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
enableReRanking
|
|
112
|
+
reRankingApplyFilter {
|
|
113
|
+
__typename
|
|
114
|
+
... on AlgoliaString_container {
|
|
115
|
+
String
|
|
116
|
+
}
|
|
117
|
+
... on AlgoliaVoid_container {
|
|
118
|
+
Void
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React, { useContext } from 'react'
|
|
2
|
+
|
|
3
|
+
export type AlgoliaQueryContextType = {
|
|
4
|
+
queryID?: string | null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const AlgoliaQueryContext = React.createContext<AlgoliaQueryContextType>({})
|
|
8
|
+
|
|
9
|
+
export function useAlgoliaQuery() {
|
|
10
|
+
return useContext(AlgoliaQueryContext)
|
|
11
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './mesh/algoliaFacetsToAggregations'
|
|
2
|
+
export * from './mesh/algoliaHitToMagentoProduct'
|
|
3
|
+
export * from './mesh/getSearchResults'
|
|
4
|
+
export * from './mesh/getSearchResultsInput'
|
|
5
|
+
export * from './mesh/getSearchSuggestions'
|
|
6
|
+
export * from './mesh/getSearchSuggestionsInput'
|
|
7
|
+
export * from './hooks/useAlgoliaIndexName'
|
|
8
|
+
export * from './hooks/useAlgoliaQueryContext'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ApolloCache, setContext } from '@graphcommerce/graphql'
|
|
2
|
+
import { CustomerDocument } from '@graphcommerce/magento-customer'
|
|
3
|
+
|
|
4
|
+
declare module '@apollo/client' {
|
|
5
|
+
interface DefaultContext {
|
|
6
|
+
cache?: ApolloCache<unknown>
|
|
7
|
+
headers?: Record<string, string>
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const customerGroupIdLink = setContext((_, context) => {
|
|
12
|
+
if (!context.headers) context.headers = {}
|
|
13
|
+
try {
|
|
14
|
+
const group_id = context.cache?.readQuery({ query: CustomerDocument })?.customer?.group_id
|
|
15
|
+
if (group_id) context.headers['x-magento-group-id'] = `${group_id}`
|
|
16
|
+
return context
|
|
17
|
+
} catch (error) {
|
|
18
|
+
return context
|
|
19
|
+
}
|
|
20
|
+
})
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Aggregation,
|
|
3
|
+
AggregationOption,
|
|
4
|
+
AlgoliasearchResponse,
|
|
5
|
+
CategoryResult,
|
|
6
|
+
MeshContext,
|
|
7
|
+
} from '@graphcommerce/graphql-mesh'
|
|
8
|
+
import { AttributeList } from './getAttributeList'
|
|
9
|
+
import { GetStoreConfigReturn } from './getStoreConfig'
|
|
10
|
+
|
|
11
|
+
type AlgoliaFacets = { [facetName: string]: AlgoliaFacetOption }
|
|
12
|
+
type AlgoliaFacetOption = { [facetOption: string]: number }
|
|
13
|
+
|
|
14
|
+
function categoryMapping(
|
|
15
|
+
categoryList: CategoryResult | null | undefined,
|
|
16
|
+
facetList: AlgoliaFacetOption,
|
|
17
|
+
): AggregationOption[] {
|
|
18
|
+
if (!categoryList?.items) {
|
|
19
|
+
return []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return categoryList?.items
|
|
23
|
+
?.map((category) => {
|
|
24
|
+
const count = category?.id ? facetList[category?.id] : 0
|
|
25
|
+
return { label: category?.name, value: category?.uid ?? '', count }
|
|
26
|
+
})
|
|
27
|
+
.filter((category) => category.count > 0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compare(a, b) {
|
|
31
|
+
const numberA: number = +a[0]
|
|
32
|
+
const numberB: number = +b[0]
|
|
33
|
+
|
|
34
|
+
if (numberA < numberB) {
|
|
35
|
+
return -1
|
|
36
|
+
}
|
|
37
|
+
if (numberA > numberB) {
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function algoliaPricesToPricesAggregations(pricesList: {
|
|
45
|
+
[key: string]: number
|
|
46
|
+
}): AggregationOption[] {
|
|
47
|
+
const priceArraylist: { value: number; count: number }[] = Object.entries(pricesList)
|
|
48
|
+
.sort(compare)
|
|
49
|
+
.map((price) => {
|
|
50
|
+
const value: number = +price[0]
|
|
51
|
+
return { value, count: price[1] }
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const interval = Math.round(
|
|
55
|
+
(priceArraylist[priceArraylist.length - 1].value - priceArraylist[0].value) / 2,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const pricesBucket: { [key: number]: { count: number; value: string; label: string } } = {}
|
|
59
|
+
let increasingInterval = interval
|
|
60
|
+
priceArraylist.forEach((price) => {
|
|
61
|
+
if (price.value <= increasingInterval) {
|
|
62
|
+
if (!pricesBucket[increasingInterval]) {
|
|
63
|
+
pricesBucket[increasingInterval] = {
|
|
64
|
+
count: price.count,
|
|
65
|
+
value:
|
|
66
|
+
increasingInterval === interval
|
|
67
|
+
? `0_${interval}`
|
|
68
|
+
: `${increasingInterval - interval}_${increasingInterval}`,
|
|
69
|
+
label:
|
|
70
|
+
increasingInterval === interval
|
|
71
|
+
? `0_${interval}`
|
|
72
|
+
: `${increasingInterval - interval}-${increasingInterval}`,
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
pricesBucket[increasingInterval].count += price.count
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
increasingInterval += interval
|
|
79
|
+
pricesBucket[increasingInterval] = {
|
|
80
|
+
count: price.count,
|
|
81
|
+
value:
|
|
82
|
+
increasingInterval === interval
|
|
83
|
+
? `0_${interval}`
|
|
84
|
+
: `${increasingInterval - interval}_${increasingInterval}`,
|
|
85
|
+
label:
|
|
86
|
+
increasingInterval === interval
|
|
87
|
+
? `0-${interval}`
|
|
88
|
+
: `${increasingInterval - interval}-${increasingInterval}`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
price.value === increasingInterval &&
|
|
93
|
+
priceArraylist[priceArraylist.length - 1].value !== price.value
|
|
94
|
+
) {
|
|
95
|
+
increasingInterval += interval
|
|
96
|
+
pricesBucket[increasingInterval] = {
|
|
97
|
+
count: price.count,
|
|
98
|
+
value:
|
|
99
|
+
increasingInterval === interval
|
|
100
|
+
? `0_${interval}`
|
|
101
|
+
: `${increasingInterval - interval}_${increasingInterval}`,
|
|
102
|
+
label:
|
|
103
|
+
increasingInterval === interval
|
|
104
|
+
? `0_${interval}`
|
|
105
|
+
: `${increasingInterval - interval}-${increasingInterval}`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
return Object.values(pricesBucket)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertAlgoliaFacets(facets: any): facets is AlgoliaFacets {
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Map algolia facets to aggregations format
|
|
118
|
+
*
|
|
119
|
+
* TODO: Make sure the aggregations are sorted correctly: https://magento-247-git-canary-graphcommerce.vercel.app/men/photography, through position
|
|
120
|
+
*/
|
|
121
|
+
export function algoliaFacetsToAggregations(
|
|
122
|
+
algoliaFacets: AlgoliasearchResponse['facets'],
|
|
123
|
+
attributes: AttributeList,
|
|
124
|
+
storeConfig: GetStoreConfigReturn,
|
|
125
|
+
categoryList?: null | CategoryResult,
|
|
126
|
+
groupId?: number,
|
|
127
|
+
): Aggregation[] {
|
|
128
|
+
if (!storeConfig?.default_display_currency_code) throw new Error('Currency is required')
|
|
129
|
+
const aggregations: Aggregation[] = []
|
|
130
|
+
|
|
131
|
+
if (!assertAlgoliaFacets(algoliaFacets)) throw Error('these are not facets')
|
|
132
|
+
|
|
133
|
+
Object.entries(algoliaFacets).forEach(([facetIndex, facet]) => {
|
|
134
|
+
let attribute_code = facetIndex
|
|
135
|
+
|
|
136
|
+
if (facetIndex.startsWith('categories.level')) return
|
|
137
|
+
if (facetIndex.startsWith('price')) {
|
|
138
|
+
attribute_code = 'price'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const position = 0
|
|
142
|
+
|
|
143
|
+
const label =
|
|
144
|
+
attributes?.find((attribute) => attribute?.code === attribute_code)?.label ?? attribute_code
|
|
145
|
+
if (facetIndex === 'categoryIds') {
|
|
146
|
+
aggregations.push({
|
|
147
|
+
label,
|
|
148
|
+
attribute_code: 'category_uid',
|
|
149
|
+
options: categoryMapping(categoryList, algoliaFacets[facetIndex]),
|
|
150
|
+
position,
|
|
151
|
+
})
|
|
152
|
+
} else if (facetIndex.startsWith('price')) {
|
|
153
|
+
if (!groupId && facetIndex !== `price.${storeConfig.default_display_currency_code}.default`) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
if (
|
|
157
|
+
groupId &&
|
|
158
|
+
facetIndex !== `price.${storeConfig.default_display_currency_code}.group_${groupId}`
|
|
159
|
+
) {
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
aggregations.push({
|
|
163
|
+
label,
|
|
164
|
+
attribute_code,
|
|
165
|
+
options: algoliaPricesToPricesAggregations(algoliaFacets[facetIndex]),
|
|
166
|
+
position,
|
|
167
|
+
})
|
|
168
|
+
} else {
|
|
169
|
+
// Fallback to code if no label is found
|
|
170
|
+
aggregations.push({
|
|
171
|
+
label,
|
|
172
|
+
attribute_code,
|
|
173
|
+
options: Object.entries(facet).map(([filter, count]) => ({
|
|
174
|
+
label: filter,
|
|
175
|
+
count,
|
|
176
|
+
value: filter,
|
|
177
|
+
})),
|
|
178
|
+
position,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return aggregations
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let categoryListCache: CategoryResult | null = null
|
|
187
|
+
|
|
188
|
+
export async function getCategoryList(context: MeshContext) {
|
|
189
|
+
if (categoryListCache) return categoryListCache
|
|
190
|
+
|
|
191
|
+
// context.cache.get('algolia_getCategoryList')
|
|
192
|
+
|
|
193
|
+
categoryListCache = await context.m2.Query.categories({
|
|
194
|
+
args: { filters: {} },
|
|
195
|
+
selectionSet: /* GraphQL */ `
|
|
196
|
+
{
|
|
197
|
+
items {
|
|
198
|
+
uid
|
|
199
|
+
name
|
|
200
|
+
id
|
|
201
|
+
position
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
`,
|
|
205
|
+
context,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return categoryListCache!
|
|
209
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AlgoliaPrice,
|
|
3
|
+
AlgoliaProductHitAdditionalProperties,
|
|
4
|
+
Algoliahit,
|
|
5
|
+
CurrencyEnum,
|
|
6
|
+
MeshContext,
|
|
7
|
+
PriceRange,
|
|
8
|
+
QueryproductsArgs,
|
|
9
|
+
RequireFields,
|
|
10
|
+
ResolverFn,
|
|
11
|
+
ResolversParentTypes,
|
|
12
|
+
ResolversTypes,
|
|
13
|
+
} from '@graphcommerce/graphql-mesh'
|
|
14
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
15
|
+
import { GraphQLResolveInfo } from 'graphql'
|
|
16
|
+
import { GetStoreConfigReturn } from './getStoreConfig'
|
|
17
|
+
|
|
18
|
+
export function assertAdditional(
|
|
19
|
+
additional: unknown,
|
|
20
|
+
): additional is AlgoliaProductHitAdditionalProperties {
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const algoliaTypeToTypename = {
|
|
25
|
+
bundle: 'BundleProduct',
|
|
26
|
+
simple: 'SimpleProduct',
|
|
27
|
+
configurable: 'ConfigurableProduct',
|
|
28
|
+
downloadable: 'DownloadableProduct',
|
|
29
|
+
virtual: 'VirtualProduct',
|
|
30
|
+
grouped: 'GroupedProduct',
|
|
31
|
+
giftcard: 'GiftCardProduct',
|
|
32
|
+
} as const
|
|
33
|
+
|
|
34
|
+
function mapPriceRange(
|
|
35
|
+
price: AlgoliaProductHitAdditionalProperties['price'],
|
|
36
|
+
storeConfig: GetStoreConfigReturn,
|
|
37
|
+
customerGroup = 0,
|
|
38
|
+
): PriceRange {
|
|
39
|
+
if (!storeConfig?.default_display_currency_code) throw new Error('Currency is required')
|
|
40
|
+
|
|
41
|
+
const key = storeConfig.default_display_currency_code as keyof AlgoliaPrice
|
|
42
|
+
const currency = storeConfig.default_display_currency_code as CurrencyEnum
|
|
43
|
+
|
|
44
|
+
const maxRegular = price?.[key]?.default_max ?? 0
|
|
45
|
+
const maxFinal = price?.[key]?.[`group_${customerGroup}_max`] ?? price?.[key]?.default_max ?? 0
|
|
46
|
+
|
|
47
|
+
const minRegular = price?.[key]?.default ?? 0
|
|
48
|
+
const minFinal = price?.[key]?.[`group_${customerGroup}`] ?? price?.[key]?.default
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
maximum_price: {
|
|
52
|
+
regular_price: {
|
|
53
|
+
currency,
|
|
54
|
+
value: maxRegular,
|
|
55
|
+
},
|
|
56
|
+
final_price: {
|
|
57
|
+
currency,
|
|
58
|
+
value: maxFinal,
|
|
59
|
+
},
|
|
60
|
+
discount: {
|
|
61
|
+
percent_off:
|
|
62
|
+
maxRegular !== maxFinal && maxRegular > 0 ? 1 - (maxFinal / maxRegular) * 100 : 0,
|
|
63
|
+
amount_off: maxRegular - maxFinal,
|
|
64
|
+
},
|
|
65
|
+
// fixed_product_taxes
|
|
66
|
+
},
|
|
67
|
+
minimum_price: {
|
|
68
|
+
regular_price: {
|
|
69
|
+
currency,
|
|
70
|
+
value: price?.[key]?.default,
|
|
71
|
+
},
|
|
72
|
+
final_price: {
|
|
73
|
+
currency,
|
|
74
|
+
value: minFinal,
|
|
75
|
+
},
|
|
76
|
+
discount: {
|
|
77
|
+
percent_off:
|
|
78
|
+
minRegular !== minFinal && minRegular > 0 ? 1 - (minFinal / minRegular) * 100 : 0,
|
|
79
|
+
amount_off: minRegular - minFinal,
|
|
80
|
+
},
|
|
81
|
+
// fixed_product_taxes
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function algoliaUrlToUrlKey(url?: string | null, base?: string | null): string | null {
|
|
87
|
+
if (!url || !base) return null
|
|
88
|
+
return url.replace(base, '')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* For the URL https://configurator.reachdigital.dev/media/catalog/product/cache/d911de87cf9e562637815cc5a14b1b05/1/0/1087_1_3.jpg
|
|
93
|
+
* Remove /cache/HASH from the URL but only if the url contains media/catalog/product
|
|
94
|
+
* @param url
|
|
95
|
+
*/
|
|
96
|
+
function getOriginalImage(url?: string | undefined | null) {
|
|
97
|
+
if (!url || !url.includes('media/catalog/product')) return url
|
|
98
|
+
return url.replace(/\/cache\/[a-z0-9]+/, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type ProductsItemsItem = NonNullable<
|
|
102
|
+
Awaited<
|
|
103
|
+
ReturnType<
|
|
104
|
+
ResolverFn<
|
|
105
|
+
ResolversTypes['Products'],
|
|
106
|
+
ResolversParentTypes['Query'],
|
|
107
|
+
MeshContext,
|
|
108
|
+
RequireFields<QueryproductsArgs, 'pageSize' | 'currentPage'>
|
|
109
|
+
>
|
|
110
|
+
>
|
|
111
|
+
>['items']
|
|
112
|
+
>[number] & {
|
|
113
|
+
__typename: (typeof algoliaTypeToTypename)[keyof typeof algoliaTypeToTypename]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mapping function to map Algolia hit to Magento product.
|
|
118
|
+
*
|
|
119
|
+
* You can create a FunctionPlugin to modify the behavior of this function or implement brand specific code.
|
|
120
|
+
*/
|
|
121
|
+
export function algoliaHitToMagentoProduct(
|
|
122
|
+
hit: Algoliahit,
|
|
123
|
+
storeConfig: GetStoreConfigReturn,
|
|
124
|
+
customerGroup: number,
|
|
125
|
+
): ProductsItemsItem | null {
|
|
126
|
+
const { objectID, additionalProperties } = hit
|
|
127
|
+
if (!assertAdditional(additionalProperties)) return null
|
|
128
|
+
|
|
129
|
+
const {
|
|
130
|
+
sku,
|
|
131
|
+
created_at,
|
|
132
|
+
image_url,
|
|
133
|
+
is_stock,
|
|
134
|
+
|
|
135
|
+
price,
|
|
136
|
+
thumbnail_url,
|
|
137
|
+
type_id,
|
|
138
|
+
url,
|
|
139
|
+
|
|
140
|
+
// not used
|
|
141
|
+
ordered_qty,
|
|
142
|
+
visibility_catalog,
|
|
143
|
+
visibility_search,
|
|
144
|
+
rating_summary,
|
|
145
|
+
|
|
146
|
+
// The rest will be spread into the product
|
|
147
|
+
...rest
|
|
148
|
+
} = additionalProperties
|
|
149
|
+
|
|
150
|
+
// Some custom attributes are returned as array while they need to be a string. Flatten those arrays
|
|
151
|
+
const flattenedCustomAttributes = {}
|
|
152
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
153
|
+
if (value !== null && Array.isArray(value) && value?.length > 0) {
|
|
154
|
+
flattenedCustomAttributes[key] = value.toString()
|
|
155
|
+
delete rest[key]
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
redirect_code: 0,
|
|
161
|
+
__typename: algoliaTypeToTypename[type_id as keyof typeof algoliaTypeToTypename],
|
|
162
|
+
uid: btoa(objectID),
|
|
163
|
+
sku: Array.isArray(sku) ? sku[0] : `${sku}`,
|
|
164
|
+
price_range: mapPriceRange(price, storeConfig, customerGroup),
|
|
165
|
+
created_at: created_at ? new Date(created_at).toISOString() : null,
|
|
166
|
+
stock_status: is_stock ? 'IN_STOCK' : 'OUT_OF_STOCK',
|
|
167
|
+
review_count: 0,
|
|
168
|
+
rating_summary: Number(rating_summary),
|
|
169
|
+
reviews: { items: [], page_info: {} },
|
|
170
|
+
// canonical_url: null,
|
|
171
|
+
// categories: [],
|
|
172
|
+
// country_of_manufacture: null,
|
|
173
|
+
// crosssell_products: [],
|
|
174
|
+
// custom_attributesV2: null,
|
|
175
|
+
// description: null,
|
|
176
|
+
// gift_message_available: null,
|
|
177
|
+
image: { url: getOriginalImage(image_url) },
|
|
178
|
+
// media_gallery: [],
|
|
179
|
+
// meta_keyword: null,
|
|
180
|
+
// meta_title: null,
|
|
181
|
+
// new_from_date: null,
|
|
182
|
+
// new_to_date: null,
|
|
183
|
+
// only_x_left_in_stock: null,
|
|
184
|
+
// options_container: null,
|
|
185
|
+
// price_tiers: [],
|
|
186
|
+
// product_links: [],
|
|
187
|
+
// related_products: null,
|
|
188
|
+
// short_description: null,
|
|
189
|
+
// small_image: null,
|
|
190
|
+
// special_price: null,
|
|
191
|
+
// special_to_date: null,
|
|
192
|
+
small_image: { url: getOriginalImage(thumbnail_url) },
|
|
193
|
+
swatch_image: getOriginalImage(image_url),
|
|
194
|
+
thumbnail: { url: getOriginalImage(thumbnail_url) },
|
|
195
|
+
// upsell_products: [],
|
|
196
|
+
url_key: algoliaUrlToUrlKey(url, storeConfig?.base_link_url),
|
|
197
|
+
url_suffix: storeConfig?.product_url_suffix,
|
|
198
|
+
...rest,
|
|
199
|
+
...flattenedCustomAttributes,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AlgoliasettingsResponse, MeshContext } from '@graphcommerce/graphql-mesh'
|
|
2
|
+
import { getIndexName } from './getIndexName'
|
|
3
|
+
|
|
4
|
+
let settingsCache: AlgoliasettingsResponse | null = null
|
|
5
|
+
|
|
6
|
+
export async function getAlgoliaSettings(context: MeshContext): Promise<AlgoliasettingsResponse> {
|
|
7
|
+
if (!settingsCache) {
|
|
8
|
+
settingsCache = await context.algolia.Query.algolia_getSettings({
|
|
9
|
+
args: { indexName: getIndexName(context) },
|
|
10
|
+
selectionSet: /* GraphQL */ `
|
|
11
|
+
{
|
|
12
|
+
replicas
|
|
13
|
+
}
|
|
14
|
+
`,
|
|
15
|
+
context,
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!settingsCache) throw Error('No settings found')
|
|
20
|
+
return settingsCache
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MeshContext } from '@graphcommerce/graphql-mesh'
|
|
2
|
+
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
3
|
+
import { filterNonNullableKeys } from '@graphcommerce/next-ui/RenderType/filterNonNullableKeys'
|
|
4
|
+
|
|
5
|
+
export type AttributeList = { label: string; code: string }[]
|
|
6
|
+
|
|
7
|
+
let attributeListCache: AttributeList | null = null
|
|
8
|
+
|
|
9
|
+
export async function getAttributeList(context: MeshContext): Promise<AttributeList> {
|
|
10
|
+
if (attributeListCache) return attributeListCache
|
|
11
|
+
|
|
12
|
+
if (
|
|
13
|
+
import.meta.graphCommerce.magentoVersion >= 247 &&
|
|
14
|
+
'attributesList' in context.m2.Query &&
|
|
15
|
+
typeof context.m2.Query.attributesList === 'function'
|
|
16
|
+
) {
|
|
17
|
+
const items = (await context.m2.Query.attributesList({
|
|
18
|
+
args: {
|
|
19
|
+
entityType: 'CATALOG_PRODUCT',
|
|
20
|
+
filters: {},
|
|
21
|
+
},
|
|
22
|
+
selectionSet: `{ items{ code label } }`,
|
|
23
|
+
context,
|
|
24
|
+
}).then((res) => res?.items)) as { label?: string; code: string }[]
|
|
25
|
+
attributeListCache = filterNonNullableKeys(items, ['label'])
|
|
26
|
+
|
|
27
|
+
return filterNonNullableKeys(items, ['label'])
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return []
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MeshContext } from '@graphcommerce/graphql-mesh'
|
|
2
|
+
|
|
3
|
+
export function getGroupId(context: MeshContext): number {
|
|
4
|
+
const { headers } = context as MeshContext & { headers?: Record<string, string | undefined> }
|
|
5
|
+
if (!headers?.authorization) return 0
|
|
6
|
+
return parseInt(headers?.['x-magento-group-id'] || '0', 10)
|
|
7
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MeshContext } from '@graphcommerce/graphql-mesh'
|
|
2
|
+
import { storefrontConfigDefault } from '@graphcommerce/next-ui'
|
|
3
|
+
|
|
4
|
+
function getStoreHeader(context: MeshContext) {
|
|
5
|
+
return (context as MeshContext & { headers: Record<string, string | undefined> }).headers.store
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getIndexName(context: MeshContext) {
|
|
9
|
+
const storeCode = getStoreHeader(context) ?? storefrontConfigDefault().magentoStoreCode
|
|
10
|
+
return `${import.meta.graphCommerce.algolia.indexNamePrefix}${storeCode}_products`
|
|
11
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { MeshContext, QueryproductsArgs } from '@graphcommerce/graphql-mesh'
|
|
2
|
+
import type { GraphQLResolveInfo } from 'graphql'
|
|
3
|
+
import { getAlgoliaSettings } from './getAlgoliaSettings'
|
|
4
|
+
import { getSearchResultsInput } from './getSearchResultsInput'
|
|
5
|
+
import { getSortedIndex } from './sortOptions'
|
|
6
|
+
|
|
7
|
+
export async function getSearchResults(
|
|
8
|
+
args: QueryproductsArgs,
|
|
9
|
+
context: MeshContext,
|
|
10
|
+
info: GraphQLResolveInfo,
|
|
11
|
+
) {
|
|
12
|
+
return context.algolia.Query.algolia_searchSingleIndex({
|
|
13
|
+
args: {
|
|
14
|
+
indexName: await getSortedIndex(context, getAlgoliaSettings(context), args.sort),
|
|
15
|
+
input: await getSearchResultsInput(args, context),
|
|
16
|
+
},
|
|
17
|
+
selectionSet: /* GraphQL */ `
|
|
18
|
+
{
|
|
19
|
+
nbPages
|
|
20
|
+
hitsPerPage
|
|
21
|
+
page
|
|
22
|
+
queryID
|
|
23
|
+
nbHits
|
|
24
|
+
hits {
|
|
25
|
+
__typename
|
|
26
|
+
objectID
|
|
27
|
+
additionalProperties
|
|
28
|
+
}
|
|
29
|
+
facets
|
|
30
|
+
}
|
|
31
|
+
`,
|
|
32
|
+
context,
|
|
33
|
+
info,
|
|
34
|
+
})
|
|
35
|
+
}
|