@graphcommerce/algolia-personalization 9.0.0-canary.73

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.
@@ -0,0 +1,377 @@
1
+ /* eslint-disable arrow-body-style */
2
+ import { useAlgoliaIndexName, useAlgoliaQuery } from '@graphcommerce/algolia-mesh'
3
+ import { GoogleEventTypes, sendEvent } from '@graphcommerce/google-datalayer'
4
+ import { useApolloClient } from '@graphcommerce/graphql'
5
+ import type { AlgoliaEventsItems_Input } from '@graphcommerce/graphql-mesh'
6
+ import { CustomerDocument } from '@graphcommerce/magento-customer/hooks/Customer.gql'
7
+ import { cookie } from '@graphcommerce/next-ui'
8
+ import { useDebounce } from '@graphcommerce/react-hook-form'
9
+ import { useEventCallback } from '@mui/material'
10
+ import { useRef } from 'react'
11
+ import { AlgoliaSendEventDocument } from '../mutations/AlgoliaSendEvent.gql'
12
+ import { isFilterTypeEqual, ProductFilterParams } from '@graphcommerce/magento-product'
13
+
14
+ const getSHA256Hash = async (input: string) => {
15
+ const textAsBuffer = new TextEncoder().encode(input)
16
+ const hashBuffer = await window.crypto.subtle.digest('SHA-256', textAsBuffer)
17
+ const hashArray = Array.from(new Uint8Array(hashBuffer))
18
+ const hash = hashArray.map((item) => item.toString(16).padStart(2, '0')).join('')
19
+ return hash
20
+ }
21
+
22
+ function mapSelectedFiltersToAlgoliaEvent(filters: ProductFilterParams['filters']) {
23
+ const flattenedFilters: string[] = []
24
+
25
+ Object.entries(filters).forEach(([key, filter]) => {
26
+ if (isFilterTypeEqual(filter)) {
27
+ const valueArray = (filter.eq ? [filter.eq] : (filter.in ?? [])) as string[]
28
+ valueArray.forEach((value) => {
29
+ if (key === 'category_uid') {
30
+ flattenedFilters.push(`categoryIds:${atob(value)}`)
31
+ } else {
32
+ flattenedFilters.push(`${key}:${encodeURIComponent(value)}`)
33
+ }
34
+ })
35
+ }
36
+
37
+ // if (isFilterTypeMatch(value)) return null
38
+ // if (isFilterTypeRange(value)) return null
39
+ })
40
+
41
+ return flattenedFilters
42
+ }
43
+
44
+ const LOCAL_STORAGE_KEY = '_algolia_conversion'
45
+ function getObjectIDToQuery(): Record<string, { queryID: string; filters: string[] }> {
46
+ return JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || 'null') ?? {}
47
+ }
48
+
49
+ function clearAlgoliaIdToQuery() {
50
+ window.localStorage.removeItem(LOCAL_STORAGE_KEY)
51
+ }
52
+
53
+ function saveAlgoliaIdToQuery(
54
+ objectIDs: string[],
55
+ queryID: string,
56
+ incomingFilters: ProductFilterParams['filters'],
57
+ ) {
58
+ const current = getObjectIDToQuery()
59
+ const filters = mapSelectedFiltersToAlgoliaEvent(incomingFilters)
60
+
61
+ objectIDs.forEach((objectID) => {
62
+ current[objectID] = { queryID, filters }
63
+ })
64
+
65
+ window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(current))
66
+ }
67
+
68
+ type AlgoliaEventCommon = {
69
+ index: string
70
+ userToken: string
71
+ authenticatedUserToken?: string
72
+ // timestamp: bigint
73
+ queryID?: string
74
+ }
75
+
76
+ let prevFilters: string[] = []
77
+
78
+ const dataLayerToAlgoliaMap: {
79
+ [K in keyof Partial<GoogleEventTypes>]: (
80
+ eventName: K,
81
+ eventData: GoogleEventTypes[K],
82
+ common: AlgoliaEventCommon,
83
+ ) => AlgoliaEventsItems_Input[]
84
+ } = {
85
+ // todo should we use view_item or view_item_list?
86
+ view_item_list: (eventName, eventData, { queryID, ...common }) => {
87
+ const objectIDs = eventData.items.map((item) => atob(item.item_uid))
88
+
89
+ const events: AlgoliaEventsItems_Input[] = []
90
+
91
+ console.log(queryID)
92
+ if (
93
+ // Values of filters are different when using Algolia vs Magento, thus it does not make sense to send the filters
94
+ queryID &&
95
+ eventData.filter_params?.filters &&
96
+ Object.keys(eventData.filter_params?.filters).length > 0
97
+ ) {
98
+ const filters = mapSelectedFiltersToAlgoliaEvent(eventData.filter_params.filters)
99
+
100
+ const newlyAppliedFilters = filters.filter((filter) => !prevFilters.includes(filter))
101
+ prevFilters = filters
102
+
103
+ if (newlyAppliedFilters) {
104
+ events.push({
105
+ Clicked_filters_Input: {
106
+ eventName: `${eventName}_filter_diff`,
107
+ eventType: 'click',
108
+ filters: newlyAppliedFilters,
109
+ ...common,
110
+ },
111
+ })
112
+ }
113
+
114
+ // There is a max of 10 filters per event, if there are more than 10 items
115
+ // we need to split the event into multiple events
116
+ for (let i = 0; i < filters.length; i += 10) {
117
+ events.push({
118
+ Viewed_filters_Input: {
119
+ eventName: `${eventName}_filters`,
120
+ eventType: 'view',
121
+ filters: filters.slice(i, i + 10),
122
+ ...common,
123
+ },
124
+ })
125
+ }
126
+ }
127
+
128
+ // There is a max of 20 ObjectIDs per event, if there are more than 20 items
129
+ // we need to split the event into multiple events
130
+ for (let i = 0; i < objectIDs.length; i += 20) {
131
+ events.push({
132
+ Viewed_object_IDs_Input: {
133
+ objectIDs: objectIDs.slice(i, i + 20),
134
+ eventName,
135
+ eventType: 'view',
136
+ ...common,
137
+ },
138
+ })
139
+ }
140
+
141
+ return events
142
+ },
143
+
144
+ select_item: (eventName, eventData, { queryID, ...common }) => {
145
+ const objectIDs = eventData.items.map((item) => atob(item.item_uid))
146
+ if (queryID) saveAlgoliaIdToQuery(objectIDs, queryID, eventData.filter_params?.filters ?? {})
147
+
148
+ return queryID
149
+ ? [
150
+ {
151
+ Clicked_object_IDs_after_search_Input: {
152
+ eventName,
153
+ eventType: 'click',
154
+ objectIDs,
155
+ positions: eventData.items.map((item) => item.index + 1),
156
+ queryID,
157
+ ...common,
158
+ },
159
+ } satisfies AlgoliaEventsItems_Input,
160
+ ]
161
+ : [
162
+ {
163
+ Clicked_object_IDs_Input: {
164
+ eventName,
165
+ eventType: 'click',
166
+ objectIDs: eventData.items.map((item) => atob(item.item_uid)),
167
+ ...common,
168
+ },
169
+ } satisfies AlgoliaEventsItems_Input,
170
+ ]
171
+ },
172
+ add_to_cart: (eventName, eventData, { queryID: _, ...common }) => {
173
+ // It seems that these two events are 'simplified' versions of the Add_to_cart events.
174
+ // - Converted_object_IDs_after_search_Input
175
+ // - Converted_object_IDs_Input
176
+
177
+ const events: AlgoliaEventsItems_Input[] = []
178
+
179
+ const mapping = getObjectIDToQuery()
180
+ const objectIDs = eventData.items.map((item) => atob(item.item_uid))
181
+
182
+ const relevant = objectIDs.map((objectID) => mapping[objectID])
183
+ const queryID = relevant?.[0]?.queryID
184
+ const filters = [...new Set(...relevant.map((item) => item.filters))]
185
+
186
+ if (filters.length) {
187
+ // There is a max of 10 filters per event, if there are more than 10 items
188
+ // we need to split the event into multiple events
189
+ for (let i = 0; i < filters.length; i += 10) {
190
+ events.push({
191
+ Converted_filters_Input: {
192
+ eventName: `${eventName}_filters`,
193
+ eventType: 'conversion',
194
+ filters: filters.slice(i, i + 10),
195
+ ...common,
196
+ },
197
+ })
198
+ }
199
+ }
200
+
201
+ if (queryID) {
202
+ events.push({
203
+ Added_to_cart_object_IDs_after_search_Input: {
204
+ queryID,
205
+ eventName,
206
+ eventType: 'conversion',
207
+ eventSubtype: 'addToCart',
208
+ objectIDs: eventData.items.map((item) => atob(item.item_uid)),
209
+ objectData: eventData.items.map((item) => ({
210
+ discount: { Float: item.discount ?? 0 },
211
+ price: { Float: Number(item.price.toFixed(15)) },
212
+ quantity: item.quantity,
213
+ })),
214
+ currency: eventData.currency,
215
+ value: { Float: Number(eventData.value.toFixed(15)) },
216
+ ...common,
217
+ },
218
+ } satisfies AlgoliaEventsItems_Input)
219
+ } else {
220
+ events.push({
221
+ Added_to_cart_object_IDs_Input: {
222
+ eventName,
223
+ eventType: 'conversion',
224
+ eventSubtype: 'addToCart',
225
+ objectIDs: eventData.items.map((item) => atob(item.item_uid)),
226
+ objectData: eventData.items.map((item) => ({
227
+ discount: { Float: item.discount ?? 0 },
228
+ price: { Float: Number(item.price.toFixed(15)) },
229
+ quantity: item.quantity,
230
+ })),
231
+ currency: eventData.currency,
232
+ value: { Float: Number(eventData.value.toFixed(15)) },
233
+ ...common,
234
+ },
235
+ } satisfies AlgoliaEventsItems_Input)
236
+ }
237
+
238
+ return events
239
+ },
240
+
241
+ purchase: (eventName, eventData, common) => {
242
+ const mapping = getObjectIDToQuery()
243
+ const isAfterSearch = !!eventData.items.find((item) => mapping[atob(item.item_uid)]?.queryID)
244
+
245
+ const events: AlgoliaEventsItems_Input[] = []
246
+
247
+ const objectIDs = eventData.items.map((item) => atob(item.item_uid))
248
+ const relevant = objectIDs.map((objectID) => mapping[objectID])
249
+ const filters = [...new Set(...relevant.map((item) => item.filters))]
250
+
251
+ if (filters.length) {
252
+ // There is a max of 10 filters per event, if there are more than 10 items
253
+ // we need to split the event into multiple events
254
+ for (let i = 0; i < filters.length; i += 10) {
255
+ events.push({
256
+ Converted_filters_Input: {
257
+ eventName: `${eventName}_filters`,
258
+ eventType: 'conversion',
259
+ filters: filters.slice(i, i + 10),
260
+ ...common,
261
+ },
262
+ })
263
+ }
264
+ }
265
+
266
+ if (isAfterSearch) {
267
+ events.push({
268
+ Purchased_object_IDs_after_search_Input: {
269
+ eventName,
270
+ eventType: 'conversion',
271
+ eventSubtype: 'purchase',
272
+ objectIDs: eventData.items.map((item) => atob(item.item_uid)),
273
+ objectData: eventData.items.map((item) => ({
274
+ discount: { Float: item.discount ?? 0 },
275
+ price: { Float: Number(item.price.toFixed(15)) },
276
+ quantity: item.quantity,
277
+ queryID: mapping[atob(item.item_uid)]?.queryID,
278
+ })),
279
+ currency: eventData.currency,
280
+ value: { Float: Number(eventData.value.toFixed(15)) },
281
+ ...common,
282
+ },
283
+ } satisfies AlgoliaEventsItems_Input)
284
+ } else {
285
+ events.push({
286
+ Purchased_object_IDs_Input: {
287
+ eventName,
288
+ eventType: 'conversion',
289
+ eventSubtype: 'purchase',
290
+ objectIDs: eventData.items.map((item) => atob(item.item_uid)),
291
+ objectData: eventData.items.map((item) => ({
292
+ discount: { Float: item.discount ?? 0 },
293
+ price: { Float: Number(item.price.toFixed(15)) },
294
+ quantity: item.quantity,
295
+ })),
296
+ currency: eventData.currency,
297
+ value: { Float: Number(eventData.value.toFixed(15)) },
298
+ ...common,
299
+ },
300
+ } satisfies AlgoliaEventsItems_Input)
301
+ }
302
+
303
+ clearAlgoliaIdToQuery()
304
+ return events
305
+ },
306
+ }
307
+
308
+ export function useSendAlgoliaEvent() {
309
+ const client = useApolloClient()
310
+ const index = useAlgoliaIndexName()
311
+ const algoliaQuery = useAlgoliaQuery()
312
+ console.log(algoliaQuery)
313
+
314
+ const eventsBuffer = useRef<AlgoliaEventsItems_Input[]>([])
315
+ const submit = useDebounce(
316
+ () => {
317
+ if (eventsBuffer.current.length === 0) return
318
+
319
+ const events = eventsBuffer.current
320
+ eventsBuffer.current = []
321
+
322
+ client
323
+ .mutate({
324
+ mutation: AlgoliaSendEventDocument,
325
+ variables: { events },
326
+ })
327
+ .then(({ data, errors }) => {
328
+ const errorMessage = (errors ?? []).map((e) => e.message)
329
+ if (errorMessage.length > 0) {
330
+ console.log('There was a problem sending the Algolia event to the server', errorMessage)
331
+ }
332
+
333
+ const response = data?.algolia_pushEvents
334
+
335
+ if (response && response.status !== 200) {
336
+ console.log(
337
+ 'There was a problem sending the Algolia event to the server Y',
338
+ response.message,
339
+ )
340
+ }
341
+ })
342
+ .catch((e) => {
343
+ console.error('There was a problem sending the Algolia event to the server Z', e)
344
+ })
345
+ },
346
+ 2000,
347
+ { trailing: true },
348
+ )
349
+
350
+ return useEventCallback<typeof sendEvent>(async (eventName, eventData) => {
351
+ const email = client.cache.readQuery({ query: CustomerDocument })?.customer?.email
352
+ const authenticatedUserToken = email ? await getSHA256Hash(email) : undefined
353
+ let userToken = cookie('_algolia_userToken')
354
+ if (!userToken) {
355
+ userToken = (Math.random() + 1).toString(36).substring(2)
356
+ cookie('_algolia_userToken', userToken, { sameSite: true })
357
+ }
358
+
359
+ // todo check if valid
360
+ if (authenticatedUserToken) {
361
+ userToken = authenticatedUserToken
362
+ }
363
+
364
+ const events = dataLayerToAlgoliaMap[eventName]?.(eventName, eventData, {
365
+ index,
366
+ userToken,
367
+ authenticatedUserToken,
368
+ queryID: algoliaQuery.queryID ?? undefined,
369
+ // timestamp: (Math.floor(Date.now() / 1000)),
370
+ })
371
+
372
+ if (events) {
373
+ eventsBuffer.current.push(...events)
374
+ submit()
375
+ }
376
+ })
377
+ }
@@ -0,0 +1,6 @@
1
+ mutation AlgoliaSendEvent($events: [AlgoliaEventsItems_Input]!) {
2
+ algolia_pushEvents(input: { events: $events }) {
3
+ message
4
+ status
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@graphcommerce/algolia-personalization",
3
+ "homepage": "https://www.graphcommerce.org/",
4
+ "repository": "github:graphcommerce-org/graphcommerce",
5
+ "version": "9.0.0-canary.73",
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
+ "scripts": {
15
+ "generate-insights": "tsx scripts/generate-insights-spec.mts"
16
+ },
17
+ "peerDependencies": {
18
+ "@graphcommerce/algolia-mesh": "^9.0.0-canary.73",
19
+ "@graphcommerce/google-datalayer": "^9.0.0-canary.73",
20
+ "@graphcommerce/graphql": "^9.0.0-canary.73",
21
+ "@graphcommerce/graphql-mesh": "^9.0.0-canary.73",
22
+ "@graphcommerce/magento-customer": "^9.0.0-canary.73",
23
+ "@graphcommerce/magento-product": "^9.0.0-canary.73",
24
+ "@graphcommerce/next-config": "^9.0.0-canary.73",
25
+ "@graphcommerce/next-ui": "^9.0.0-canary.73",
26
+ "react": "^18.2.0"
27
+ },
28
+ "devDependencies": {
29
+ "graphql": "^16.0.0",
30
+ "tsx": "^4.16.2"
31
+ }
32
+ }
@@ -0,0 +1,72 @@
1
+ import type { meshConfig as meshConfigBase } from '@graphcommerce/graphql-mesh/meshConfig'
2
+ import type { FunctionPlugin, PluginConfig } from '@graphcommerce/next-config'
3
+
4
+ export const config: PluginConfig = {
5
+ module: '@graphcommerce/graphql-mesh/meshConfig',
6
+ type: 'function',
7
+ }
8
+
9
+ export const meshConfig: FunctionPlugin<typeof meshConfigBase> = (
10
+ prev,
11
+ baseConfig,
12
+ graphCommerceConfig,
13
+ ) => {
14
+ if (!graphCommerceConfig.algoliaApplicationId || !graphCommerceConfig.algoliaSearchOnlyApiKey) {
15
+ console.log('Algolia credentials not provided, skipping Algolia plugin')
16
+ return prev(baseConfig, graphCommerceConfig)
17
+ }
18
+
19
+ return prev(
20
+ {
21
+ ...baseConfig,
22
+ sources: [
23
+ ...baseConfig.sources,
24
+ {
25
+ name: 'algoliaInsights',
26
+ handler: {
27
+ openapi: {
28
+ endpoint: `https://insights.algolia.io/`,
29
+ source: '@graphcommerce/algolia-personalization/algolia-insights-spec.yaml',
30
+ ignoreErrorResponses: true,
31
+ schemaHeaders: {
32
+ 'X-Algolia-Application-Id': graphCommerceConfig.algoliaApplicationId,
33
+ 'X-Algolia-API-Key': graphCommerceConfig.algoliaSearchOnlyApiKey,
34
+ },
35
+ operationHeaders: {
36
+ 'X-Algolia-Application-Id': graphCommerceConfig.algoliaApplicationId,
37
+ 'X-Algolia-API-Key': graphCommerceConfig.algoliaSearchOnlyApiKey,
38
+ },
39
+ selectQueryOrMutationField: [
40
+ { type: 'Query', fieldName: 'searchSingleIndex' },
41
+ { type: 'Query', fieldName: 'searchForFacetValues' },
42
+ ],
43
+ },
44
+ },
45
+ transforms: [
46
+ {
47
+ prefix: {
48
+ value: 'algolia_',
49
+ includeRootOperations: true,
50
+ includeTypes: false,
51
+ mode: 'bare',
52
+ },
53
+ },
54
+ {
55
+ prefix: {
56
+ value: 'Algolia',
57
+ includeRootOperations: false,
58
+ includeTypes: true,
59
+ mode: 'bare',
60
+ },
61
+ },
62
+ ],
63
+ },
64
+ ],
65
+ // additionalResolvers: [
66
+ // ...(baseConfig.additionalResolvers ?? []),
67
+ // '@graphcommerce/algolia-mesh/mesh/resolvers.ts',
68
+ // ],
69
+ },
70
+ graphCommerceConfig,
71
+ )
72
+ }
@@ -0,0 +1,22 @@
1
+ import type {
2
+ sendEvent,
3
+ useSendEvent as useSendEventBase,
4
+ } from '@graphcommerce/google-datalayer/api/sendEvent'
5
+ import type { FunctionPlugin, PluginConfig } from '@graphcommerce/next-config'
6
+ import { useEventCallback } from '@mui/material'
7
+ import { useSendAlgoliaEvent } from '../hooks/useSendAlgoliaEvent'
8
+
9
+ export const config: PluginConfig = {
10
+ module: '@graphcommerce/google-datalayer',
11
+ type: 'function',
12
+ }
13
+
14
+ export const useSendEvent: FunctionPlugin<typeof useSendEventBase> = (prev) => {
15
+ const originalSendEvent = prev()
16
+ const sendAlgoliaEvent = useSendAlgoliaEvent()
17
+
18
+ return useEventCallback<typeof sendEvent>((eventName, eventData) => {
19
+ originalSendEvent(eventName, eventData)
20
+ sendAlgoliaEvent(eventName, eventData)
21
+ })
22
+ }
@@ -0,0 +1,72 @@
1
+ import yaml from 'js-yaml'
2
+ import { writeFile, readFile } from 'node:fs/promises'
3
+ import { OpenAPIV3 } from 'openapi-types'
4
+ import prettier from 'prettier'
5
+ import conf from '@graphcommerce/prettier-config-pwa'
6
+
7
+ const response = await fetch(
8
+ 'https://raw.githubusercontent.com/algolia/api-clients-automation/main/specs/bundled/insights.yml',
9
+ )
10
+
11
+ const openApiSchema = yaml.load(await response.text()) as OpenAPIV3.Document
12
+
13
+ const allMethods = [
14
+ OpenAPIV3.HttpMethods.TRACE,
15
+ OpenAPIV3.HttpMethods.POST,
16
+ OpenAPIV3.HttpMethods.PUT,
17
+ OpenAPIV3.HttpMethods.GET,
18
+ OpenAPIV3.HttpMethods.DELETE,
19
+ OpenAPIV3.HttpMethods.PATCH,
20
+ OpenAPIV3.HttpMethods.OPTIONS,
21
+ OpenAPIV3.HttpMethods.HEAD,
22
+ ]
23
+
24
+ const { info, openapi, components, tags, ...rest } = openApiSchema
25
+
26
+ function filterPaths(
27
+ paths: OpenAPIV3.PathsObject,
28
+ allow: Record<string, OpenAPIV3.HttpMethods[]>,
29
+ ): OpenAPIV3.PathsObject {
30
+ const allowedEntries = Object.entries(allow)
31
+
32
+ return Object.fromEntries(
33
+ Object.entries(paths)
34
+ .map(([path, pathItem]) => {
35
+ if (!pathItem) return [path, pathItem]
36
+ const newValue = pathItem
37
+
38
+ const [allowedPath, allowedMethods] =
39
+ allowedEntries.find(([allowedPath]) => allowedPath === path) ?? []
40
+
41
+ if (!allowedPath || !allowedMethods) return [path, undefined]
42
+
43
+ allMethods
44
+ .filter((method) => !allowedMethods.includes(method))
45
+ .forEach((method) => {
46
+ newValue[method] = undefined
47
+ })
48
+
49
+ return [path, newValue]
50
+ })
51
+ .filter(([path, pathItem]) => {
52
+ if (!pathItem) return false
53
+ if (allMethods.every((key) => !pathItem[key])) return false
54
+ return true
55
+ }),
56
+ )
57
+ }
58
+
59
+ const newSchema: OpenAPIV3.Document = {
60
+ openapi,
61
+ info,
62
+ paths: filterPaths(openApiSchema.paths, { '/1/events': [OpenAPIV3.HttpMethods.POST] }),
63
+ components,
64
+ }
65
+
66
+ await writeFile(
67
+ './algolia-insights-spec.yaml',
68
+ await prettier.format(JSON.stringify(newSchema), {
69
+ parser: 'json',
70
+ ...conf,
71
+ }),
72
+ )
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
+ }