@reactionary/source 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/client/client-builder.ts +8 -0
- package/core/src/client/client.ts +4 -0
- package/core/src/initialization.ts +4 -1
- package/core/src/providers/identity.provider.ts +5 -0
- package/core/src/providers/index.ts +2 -0
- package/core/src/providers/product-associations.provider.ts +49 -0
- package/core/src/providers/product-recommendations.provider.ts +150 -0
- package/core/src/schemas/capabilities.schema.ts +2 -0
- package/core/src/schemas/models/identifiers.model.ts +8 -0
- package/core/src/schemas/models/index.ts +1 -0
- package/core/src/schemas/models/product-recommendations.model.ts +10 -0
- package/core/src/schemas/queries/index.ts +2 -0
- package/core/src/schemas/queries/product-associations.query.ts +23 -0
- package/core/src/schemas/queries/product-recommendations.query.ts +72 -0
- package/core/src/schemas/session.schema.ts +2 -1
- package/documentation/docs/7-marketing.md +95 -0
- package/documentation/docs/8-tracking.md +1 -1
- package/examples/node/package.json +6 -6
- package/examples/node/src/basic/client-creation.spec.ts +2 -2
- package/examples/node/src/capabilities/product-recommendations.spec.ts +96 -0
- package/examples/node/src/utils.ts +3 -0
- package/package.json +1 -1
- package/providers/algolia/README.md +12 -4
- package/providers/algolia/src/core/initialize.ts +8 -2
- package/providers/algolia/src/index.ts +1 -0
- package/providers/algolia/src/providers/analytics.provider.ts +9 -7
- package/providers/algolia/src/providers/index.ts +2 -1
- package/providers/algolia/src/providers/product-recommendations.provider.ts +234 -0
- package/providers/algolia/src/providers/product-search.provider.ts +5 -4
- package/providers/algolia/src/schema/capabilities.schema.ts +2 -1
- package/providers/algolia/src/schema/product-recommendation.schema.ts +9 -0
- package/providers/algolia/src/test/analytics.spec.ts +138 -0
- package/providers/commercetools/src/providers/identity.provider.ts +8 -1
- package/providers/commercetools/src/test/caching.spec.ts +3 -3
- package/providers/commercetools/src/test/identity.spec.ts +2 -2
- package/providers/google-analytics/package.json +0 -6
- package/providers/medusa/src/core/initialize.ts +6 -0
- package/providers/medusa/src/index.ts +1 -0
- package/providers/medusa/src/providers/identity.provider.ts +34 -10
- package/providers/medusa/src/providers/product-recommendations.provider.ts +117 -0
- package/providers/medusa/src/schema/capabilities.schema.ts +1 -0
- package/providers/meilisearch/src/core/initialize.ts +6 -0
- package/providers/meilisearch/src/index.ts +1 -0
- package/providers/meilisearch/src/providers/index.ts +1 -0
- package/providers/meilisearch/src/providers/product-recommendations.provider.ts +89 -0
- package/providers/meilisearch/src/schema/capabilities.schema.ts +1 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createClient, PrimaryProvider } from '../utils.js';
|
|
4
|
+
import type { ProductSearchQueryCreateNavigationFilter } from '@reactionary/core';
|
|
5
|
+
|
|
6
|
+
const testData = {
|
|
7
|
+
product: {
|
|
8
|
+
id: 'product_10959528',
|
|
9
|
+
name: 'Manhattan 170703 cable accessory Cable kit',
|
|
10
|
+
image: 'https://images.icecat.biz/img/norm/high/10959528-2837.jpg',
|
|
11
|
+
sku: '0766623170703',
|
|
12
|
+
slug: 'manhattan-170703-cable-accessory-cable-kit-10959528',
|
|
13
|
+
},
|
|
14
|
+
productWithMultiVariants: {
|
|
15
|
+
slug: 'hp-gk859aa-mouse-office-bluetooth-laser-1600-dpi-1377612',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe.each([PrimaryProvider.MEDUSA])(
|
|
20
|
+
'Product Recommendations - Collections - %s',
|
|
21
|
+
(provider) => {
|
|
22
|
+
let client: ReturnType<typeof createClient>;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
client = createClient(provider);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should be able to return a list of products for a collection', async () => {
|
|
29
|
+
const result = await client.productRecommendations.getCollection({
|
|
30
|
+
collectionName: 'newest-arrivals',
|
|
31
|
+
numberOfRecommendations: 10,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!result.success) {
|
|
35
|
+
assert.fail(JSON.stringify(result.error));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expect(result.value.length).toBeGreaterThan(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should return an empty result for an unknown collection', async () => {
|
|
42
|
+
const result = await client.productRecommendations.getCollection({
|
|
43
|
+
collectionName: 'Unknown Collection',
|
|
44
|
+
numberOfRecommendations: 10,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!result.success) {
|
|
48
|
+
assert.fail(JSON.stringify(result.error));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
expect(result.value.length).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
describe.each([PrimaryProvider.MEILISEARCH])(
|
|
57
|
+
'Product Recommendations - Similar - %s',
|
|
58
|
+
(provider) => {
|
|
59
|
+
let client: ReturnType<typeof createClient>;
|
|
60
|
+
|
|
61
|
+
beforeEach(() => {
|
|
62
|
+
client = createClient(provider);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should be able to return a list of products for recommendation - Similar ', async () => {
|
|
66
|
+
const result = await client.productRecommendations.getRecommendations({
|
|
67
|
+
algorithm: 'similar',
|
|
68
|
+
sourceProduct: {
|
|
69
|
+
key: testData.product.id,
|
|
70
|
+
},
|
|
71
|
+
numberOfRecommendations: 10,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!result.success) {
|
|
75
|
+
assert.fail(JSON.stringify(result.error));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
expect(result.value.length).toBeGreaterThan(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return an empty result for an unknown sku', async () => {
|
|
82
|
+
const result = await client.productRecommendations.getRecommendations({
|
|
83
|
+
algorithm: 'similar',
|
|
84
|
+
sourceProduct: {
|
|
85
|
+
key: 'unknown-product-id',
|
|
86
|
+
},
|
|
87
|
+
numberOfRecommendations: 10,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!result.success) {
|
|
91
|
+
assert.fail(JSON.stringify(result.error));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(result.value.length).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -91,6 +91,7 @@ export function createClient(provider: PrimaryProvider) {
|
|
|
91
91
|
order: true,
|
|
92
92
|
price: true,
|
|
93
93
|
productSearch: true,
|
|
94
|
+
productRecommendations: true,
|
|
94
95
|
orderSearch: true,
|
|
95
96
|
store: true,
|
|
96
97
|
profile: true
|
|
@@ -124,6 +125,7 @@ export function createClient(provider: PrimaryProvider) {
|
|
|
124
125
|
builder = builder.withCapability(
|
|
125
126
|
withAlgoliaCapabilities(getAlgoliaTestConfiguration(), {
|
|
126
127
|
productSearch: true,
|
|
128
|
+
productRecommendations: true,
|
|
127
129
|
})
|
|
128
130
|
);
|
|
129
131
|
}
|
|
@@ -133,6 +135,7 @@ export function createClient(provider: PrimaryProvider) {
|
|
|
133
135
|
withMeilisearchCapabilities(getMeilisearchTestConfiguration(), {
|
|
134
136
|
productSearch: true,
|
|
135
137
|
orderSearch: true,
|
|
138
|
+
productRecommendations: true,
|
|
136
139
|
}),
|
|
137
140
|
);
|
|
138
141
|
builder = builder.withCapability(
|
package/package.json
CHANGED
|
@@ -38,11 +38,19 @@ You can have more, for use with facets, and additional searchable fields, but th
|
|
|
38
38
|
|
|
39
39
|
The `objectID` corrosponds to your productIdentifier, and `variantID` should match your SKU
|
|
40
40
|
|
|
41
|
+
## Analytics
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
The Algolia analytics provider maps the following tracked event types to data tracked in Algolia:
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
- AnalyticsMutationProductSummaryViewEvent => ViewedObjectIDs
|
|
46
|
+
- AnalyticsMutationProductSummaryClickEvent => ClickedObjectIDsAfterSearch / ClickedObjectIDs
|
|
47
|
+
- AnalyticsMutationProductAddToCartEvent => AddedToCartObjectIDsAfterSearch / AddedToCartObjectIDs
|
|
48
|
+
- AnalyticsMutationPurchaseEvent => PurchasedObjectIDs
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
The `AfterSearch` variants are (with the exception of purchase) preferred by the provider in the cases where Algolia is the source of the events. For search or recommendation this would typically be the case, but not necesarily for users arriving on a PDP as a direct target from a search or a link.
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
Note that we do not map `PurchasedObjectIDsAfterSearch` as it would require us to persist the search query ID that lead to the add-to-cart occuring on the cart items. This currently seems like an excess burden to impose on the cart interface.
|
|
53
|
+
|
|
54
|
+
The `ConvertedObjectIDs` and `ConvertedObjectIDsAfterSearch` are not mapped as they seem superfluous by all accounts in a product-purchase based flow. They could likely be used for other types of conversions in a more general setup, such as a customer finishing reading an article.
|
|
55
|
+
|
|
56
|
+
Finally the events that are related to filtering are not mapped, as they are by all accounts deprecated and no longer influence any of the recommendation or personalization features.
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import type { Cache, ClientFromCapabilities, RequestContext } from "@reactionary/core";
|
|
2
|
-
import {
|
|
2
|
+
import { AlgoliaProductSearchProvider } from "../providers/product-search.provider.js";
|
|
3
3
|
import type { AlgoliaCapabilities } from "../schema/capabilities.schema.js";
|
|
4
4
|
import type { AlgoliaConfiguration } from "../schema/configuration.schema.js";
|
|
5
5
|
import { AlgoliaAnalyticsProvider } from "../providers/analytics.provider.js";
|
|
6
|
+
import { AlgoliaProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
|
|
6
7
|
|
|
7
8
|
export function withAlgoliaCapabilities<T extends AlgoliaCapabilities>(configuration: AlgoliaConfiguration, capabilities: T) {
|
|
8
9
|
return (cache: Cache, context: RequestContext): ClientFromCapabilities<T> => {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
11
|
const client: any = {};
|
|
10
12
|
|
|
11
13
|
if (capabilities.productSearch) {
|
|
12
|
-
client.productSearch = new
|
|
14
|
+
client.productSearch = new AlgoliaProductSearchProvider(cache, context, configuration);
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
if (capabilities.analytics) {
|
|
16
18
|
client.analytics = new AlgoliaAnalyticsProvider(cache, context, configuration);
|
|
17
19
|
}
|
|
18
20
|
|
|
21
|
+
if (capabilities.productRecommendations) {
|
|
22
|
+
client.productRecommendations = new AlgoliaProductRecommendationsProvider(configuration, cache, context);
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
return client;
|
|
20
26
|
};
|
|
21
27
|
}
|
|
@@ -8,12 +8,12 @@ import {
|
|
|
8
8
|
type RequestContext,
|
|
9
9
|
} from '@reactionary/core';
|
|
10
10
|
import {
|
|
11
|
-
insightsClient,
|
|
12
11
|
type InsightsClient,
|
|
13
12
|
type ViewedObjectIDs,
|
|
14
13
|
type ClickedObjectIDsAfterSearch,
|
|
15
14
|
type AddedToCartObjectIDsAfterSearch,
|
|
16
15
|
type PurchasedObjectIDs,
|
|
16
|
+
algoliasearch,
|
|
17
17
|
} from 'algoliasearch';
|
|
18
18
|
import type { AlgoliaConfiguration } from '../schema/configuration.schema.js';
|
|
19
19
|
import type { AlgoliaProductSearchIdentifier } from '../schema/search.schema.js';
|
|
@@ -30,7 +30,7 @@ export class AlgoliaAnalyticsProvider extends AnalyticsProvider {
|
|
|
30
30
|
super(cache, requestContext);
|
|
31
31
|
|
|
32
32
|
this.config = config;
|
|
33
|
-
this.client =
|
|
33
|
+
this.client = algoliasearch(this.config.appId, this.config.apiKey).initInsights({});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
protected override async processProductAddToCart(
|
|
@@ -48,7 +48,7 @@ export class AlgoliaAnalyticsProvider extends AnalyticsProvider {
|
|
|
48
48
|
.key,
|
|
49
49
|
} satisfies AddedToCartObjectIDsAfterSearch;
|
|
50
50
|
|
|
51
|
-
this.client.pushEvents({
|
|
51
|
+
const response = await this.client.pushEvents({
|
|
52
52
|
events: [algoliaEvent],
|
|
53
53
|
});
|
|
54
54
|
}
|
|
@@ -69,7 +69,7 @@ export class AlgoliaAnalyticsProvider extends AnalyticsProvider {
|
|
|
69
69
|
.key,
|
|
70
70
|
} satisfies ClickedObjectIDsAfterSearch;
|
|
71
71
|
|
|
72
|
-
this.client.pushEvents({
|
|
72
|
+
const response = await this.client.pushEvents({
|
|
73
73
|
events: [algoliaEvent],
|
|
74
74
|
});
|
|
75
75
|
}
|
|
@@ -87,7 +87,7 @@ export class AlgoliaAnalyticsProvider extends AnalyticsProvider {
|
|
|
87
87
|
userToken: this.context.session.identityContext.personalizationKey,
|
|
88
88
|
} satisfies ViewedObjectIDs;
|
|
89
89
|
|
|
90
|
-
this.client.pushEvents({
|
|
90
|
+
const response = await this.client.pushEvents({
|
|
91
91
|
events: [algoliaEvent],
|
|
92
92
|
});
|
|
93
93
|
}
|
|
@@ -96,16 +96,18 @@ export class AlgoliaAnalyticsProvider extends AnalyticsProvider {
|
|
|
96
96
|
protected override async processPurchase(
|
|
97
97
|
event: AnalyticsMutationPurchaseEvent
|
|
98
98
|
): Promise<void> {
|
|
99
|
+
// TODO: Figure out how to handle the problem below. From the order we have the SKUs,
|
|
100
|
+
// but in Algolia we have the products indexed, and we can't really resolve it here...
|
|
99
101
|
const algoliaEvent = {
|
|
100
102
|
eventName: 'purchase',
|
|
101
103
|
eventType: 'conversion',
|
|
102
104
|
eventSubtype: 'purchase',
|
|
103
105
|
index: this.config.indexName,
|
|
104
|
-
objectIDs: event.order.items.map((x) => x.
|
|
106
|
+
objectIDs: event.order.items.map((x) => x.variant.sku),
|
|
105
107
|
userToken: this.context.session.identityContext.personalizationKey,
|
|
106
108
|
} satisfies PurchasedObjectIDs;
|
|
107
109
|
|
|
108
|
-
this.client.pushEvents({
|
|
110
|
+
const response = await this.client.pushEvents({
|
|
109
111
|
events: [algoliaEvent],
|
|
110
112
|
});
|
|
111
113
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Cache,
|
|
3
|
+
ProductRecommendationsProvider,
|
|
4
|
+
type ProductRecommendation,
|
|
5
|
+
type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery,
|
|
6
|
+
type ProductRecommendationAlgorithmSimilarProductsQuery,
|
|
7
|
+
type ProductRecommendationAlgorithmRelatedProductsQuery,
|
|
8
|
+
type ProductRecommendationAlgorithmTrendingInCategoryQuery,
|
|
9
|
+
type RequestContext,
|
|
10
|
+
type ProductRecommendationsQuery,
|
|
11
|
+
} from '@reactionary/core';
|
|
12
|
+
import { recommendClient, type BoughtTogetherQuery, type LookingSimilarQuery, type RecommendationsResults, type RecommendClient, type RecommendSearchParams, type RelatedQuery, type TrendingItemsQuery } from 'algoliasearch';
|
|
13
|
+
import type { AlgoliaConfiguration } from '../schema/configuration.schema.js';
|
|
14
|
+
import type { AlgoliaProductRecommendationIdentifier } from '../schema/product-recommendation.schema.js';
|
|
15
|
+
|
|
16
|
+
interface AlgoliaRecommendHit {
|
|
17
|
+
objectID: string;
|
|
18
|
+
sku?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* AlgoliaProductRecommendationsProvider
|
|
24
|
+
*
|
|
25
|
+
* Provides product recommendations using Algolia's Recommend API.
|
|
26
|
+
* Supports frequentlyBoughtTogether, similar, related, and trendingInCategory algorithms.
|
|
27
|
+
*
|
|
28
|
+
* Note: This requires Algolia Recommend to be enabled and AI models to be trained.
|
|
29
|
+
* See: https://www.algolia.com/doc/guides/algolia-recommend/overview/
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export class AlgoliaProductRecommendationsProvider extends ProductRecommendationsProvider {
|
|
36
|
+
protected config: AlgoliaConfiguration;
|
|
37
|
+
|
|
38
|
+
constructor(config: AlgoliaConfiguration, cache: Cache, context: RequestContext) {
|
|
39
|
+
super(cache, context);
|
|
40
|
+
this.config = config;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
protected getRecommendClient(): RecommendClient {
|
|
44
|
+
return recommendClient(this.config.appId, this.config.apiKey);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
48
|
+
protected getRecommendationThreshold(_algorithm: string): number {
|
|
49
|
+
// Default threshold can be customized per algorithm if needed
|
|
50
|
+
// The parameter is currently unused but kept for future algorithm-specific threshold customization
|
|
51
|
+
return 10;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
protected getQueryParametersForRecommendations(algorithm: string): RecommendSearchParams {
|
|
55
|
+
return {
|
|
56
|
+
userToken: this.context.session.identityContext?.personalizationKey || 'anonymous',
|
|
57
|
+
analytics: true,
|
|
58
|
+
analyticsTags: ['reactionary', algorithm],
|
|
59
|
+
clickAnalytics: true
|
|
60
|
+
} satisfies RecommendSearchParams;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get frequently bought together recommendations using Algolia Recommend
|
|
65
|
+
*/
|
|
66
|
+
protected override async getFrequentlyBoughtTogetherRecommendations(
|
|
67
|
+
query: ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery
|
|
68
|
+
): Promise<ProductRecommendation[]> {
|
|
69
|
+
const client = this.getRecommendClient();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// Note: Algolia's Recommend API requires setting up AI Recommend models
|
|
73
|
+
// This implementation uses the getRecommendations method from the recommend client
|
|
74
|
+
const response = await client.getRecommendations({
|
|
75
|
+
requests: [
|
|
76
|
+
{
|
|
77
|
+
indexName: this.config.indexName,
|
|
78
|
+
model: 'bought-together',
|
|
79
|
+
objectID: query.sourceProduct.key,
|
|
80
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
81
|
+
threshold: this.getRecommendationThreshold('bought-together'),
|
|
82
|
+
queryParameters: this.getQueryParametersForRecommendations('bought-together')
|
|
83
|
+
} satisfies BoughtTogetherQuery,
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const result = [];
|
|
89
|
+
if (response.results) {
|
|
90
|
+
for(const res of response.results) {
|
|
91
|
+
result.push(...this.parseRecommendation(res, query));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return result;
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('Error fetching frequently bought together recommendations:', error);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get similar product recommendations using Algolia Recommend
|
|
103
|
+
*/
|
|
104
|
+
protected override async getSimilarProductsRecommendations(
|
|
105
|
+
query: ProductRecommendationAlgorithmSimilarProductsQuery
|
|
106
|
+
): Promise<ProductRecommendation[]> {
|
|
107
|
+
const client = this.getRecommendClient();
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const response = await client.getRecommendations({
|
|
111
|
+
requests: [
|
|
112
|
+
{
|
|
113
|
+
indexName: this.config.indexName,
|
|
114
|
+
model: 'looking-similar',
|
|
115
|
+
objectID: query.sourceProduct.key,
|
|
116
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
117
|
+
threshold: this.getRecommendationThreshold('looking-similar'),
|
|
118
|
+
queryParameters: this.getQueryParametersForRecommendations('looking-similar')
|
|
119
|
+
} satisfies LookingSimilarQuery
|
|
120
|
+
],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const result = [];
|
|
124
|
+
if (response.results) {
|
|
125
|
+
for(const res of response.results) {
|
|
126
|
+
result.push(...this.parseRecommendation(res, query));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error('Error fetching similar product recommendations:', error);
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get related product recommendations using Algolia Recommend
|
|
138
|
+
*/
|
|
139
|
+
protected override async getRelatedProductsRecommendations(
|
|
140
|
+
query: ProductRecommendationAlgorithmRelatedProductsQuery
|
|
141
|
+
): Promise<ProductRecommendation[]> {
|
|
142
|
+
const client = this.getRecommendClient();
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const response = await client.getRecommendations({
|
|
146
|
+
requests: [
|
|
147
|
+
{
|
|
148
|
+
indexName: this.config.indexName,
|
|
149
|
+
model: 'related-products',
|
|
150
|
+
objectID: query.sourceProduct.key,
|
|
151
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
152
|
+
threshold: this.getRecommendationThreshold('related-products'),
|
|
153
|
+
queryParameters: this.getQueryParametersForRecommendations('related-products')
|
|
154
|
+
} satisfies RelatedQuery,
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const result = [];
|
|
159
|
+
if (response.results) {
|
|
160
|
+
for(const res of response.results) {
|
|
161
|
+
result.push(...this.parseRecommendation(res, query));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
console.error('Error fetching related product recommendations:', error);
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get trending in category recommendations using Algolia Recommend
|
|
173
|
+
*/
|
|
174
|
+
protected override async getTrendingInCategoryRecommendations(
|
|
175
|
+
query: ProductRecommendationAlgorithmTrendingInCategoryQuery
|
|
176
|
+
): Promise<ProductRecommendation[]> {
|
|
177
|
+
const client = this.getRecommendClient();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const response = await client.getRecommendations({
|
|
181
|
+
requests: [
|
|
182
|
+
{
|
|
183
|
+
indexName: this.config.indexName,
|
|
184
|
+
model: 'trending-items',
|
|
185
|
+
facetName: 'categories',
|
|
186
|
+
facetValue: query.sourceCategory.key,
|
|
187
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
188
|
+
threshold: this.getRecommendationThreshold('trending-items'),
|
|
189
|
+
queryParameters: this.getQueryParametersForRecommendations('trending-items')
|
|
190
|
+
} satisfies TrendingItemsQuery,
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const result = [];
|
|
195
|
+
if (response.results) {
|
|
196
|
+
for(const res of response.results) {
|
|
197
|
+
result.push(...this.parseRecommendation(res, query));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return result;
|
|
201
|
+
} catch (error) {
|
|
202
|
+
console.error('Error fetching trending in category recommendations:', error);
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
protected parseRecommendation(res: RecommendationsResults, query: ProductRecommendationsQuery) {
|
|
209
|
+
const result = [];
|
|
210
|
+
for(const hit of res.hits as AlgoliaRecommendHit[]) {
|
|
211
|
+
const recommendationIdentifier = {
|
|
212
|
+
key: res.queryID || 'x',
|
|
213
|
+
algorithm: query.algorithm,
|
|
214
|
+
abTestID: res.abTestID,
|
|
215
|
+
abTestVariantID: res.abTestVariantID
|
|
216
|
+
} satisfies AlgoliaProductRecommendationIdentifier
|
|
217
|
+
const recommendation = this.parseSingle(hit, recommendationIdentifier)
|
|
218
|
+
result.push(recommendation);
|
|
219
|
+
}
|
|
220
|
+
return result;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Maps Algolia recommendation results to ProductRecommendation format
|
|
225
|
+
*/
|
|
226
|
+
protected parseSingle(hit: AlgoliaRecommendHit, recommendationIdentifier: AlgoliaProductRecommendationIdentifier): ProductRecommendation {
|
|
227
|
+
return {
|
|
228
|
+
recommendationIdentifier,
|
|
229
|
+
product: {
|
|
230
|
+
key: hit.objectID,
|
|
231
|
+
},
|
|
232
|
+
} satisfies ProductRecommendation;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -40,10 +40,10 @@ interface AlgoliaNativeRecord {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
export class
|
|
43
|
+
export class AlgoliaProductSearchProvider extends ProductSearchProvider {
|
|
44
44
|
protected config: AlgoliaConfiguration;
|
|
45
45
|
|
|
46
|
-
constructor(
|
|
46
|
+
constructor(cache: Cache, context: RequestContext, config: AlgoliaConfiguration) {
|
|
47
47
|
super(cache, context);
|
|
48
48
|
this.config = config;
|
|
49
49
|
}
|
|
@@ -195,7 +195,8 @@ export class AlgoliaSearchProvider extends ProductSearchProvider {
|
|
|
195
195
|
facets: query.search.facets,
|
|
196
196
|
filters: query.search.filters,
|
|
197
197
|
paginationOptions: query.search.paginationOptions,
|
|
198
|
-
|
|
198
|
+
index: body.index || '',
|
|
199
|
+
key: body.queryID || '',
|
|
199
200
|
},
|
|
200
201
|
pageNumber: (body.page || 0) + 1,
|
|
201
202
|
pageSize: body.hitsPerPage || 0,
|
|
@@ -203,7 +204,7 @@ export class AlgoliaSearchProvider extends ProductSearchProvider {
|
|
|
203
204
|
totalPages: body.nbPages || 0,
|
|
204
205
|
items: items,
|
|
205
206
|
facets,
|
|
206
|
-
} satisfies
|
|
207
|
+
} satisfies AlgoliaProductSearchResult;
|
|
207
208
|
|
|
208
209
|
return result;
|
|
209
210
|
}
|
|
@@ -3,7 +3,8 @@ import type { z } from 'zod';
|
|
|
3
3
|
|
|
4
4
|
export const AlgoliaCapabilitiesSchema = CapabilitiesSchema.pick({
|
|
5
5
|
productSearch: true,
|
|
6
|
-
analytics: true
|
|
6
|
+
analytics: true,
|
|
7
|
+
productRecommendations: true
|
|
7
8
|
}).partial();
|
|
8
9
|
|
|
9
10
|
export type AlgoliaCapabilities = z.infer<typeof AlgoliaCapabilitiesSchema>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ProductRecommendationIdentifierSchema } from "@reactionary/core";
|
|
2
|
+
import z from "zod";
|
|
3
|
+
|
|
4
|
+
export const AlgoliaProductSearchIdentifierSchema = ProductRecommendationIdentifierSchema.extend({
|
|
5
|
+
abTestID: z.number().optional(),
|
|
6
|
+
abTestVariantID: z.number().optional()
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type AlgoliaProductRecommendationIdentifier = z.infer<typeof AlgoliaProductSearchIdentifierSchema>;
|