@reactionary/source 0.3.2 → 0.3.4
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/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/documentation/docs/7-marketing.md +95 -0
- package/examples/node/package.json +6 -6
- package/examples/node/src/capabilities/product-recommendations.spec.ts +140 -0
- package/examples/node/src/utils.ts +3 -0
- package/package.json +2 -2
- package/providers/algolia/package.json +1 -1
- package/providers/algolia/src/core/initialize.ts +6 -0
- package/providers/algolia/src/index.ts +1 -0
- package/providers/algolia/src/providers/index.ts +2 -1
- package/providers/algolia/src/providers/product-recommendations.provider.ts +236 -0
- package/providers/algolia/src/schema/capabilities.schema.ts +2 -1
- package/providers/algolia/src/schema/product-recommendation.schema.ts +9 -0
- package/providers/medusa/src/core/initialize.ts +6 -0
- package/providers/medusa/src/index.ts +1 -0
- 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,140 @@
|
|
|
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
|
+
});
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
describe.each([PrimaryProvider.ALGOLIA])(
|
|
101
|
+
'Product Recommendations - Related - %s',
|
|
102
|
+
(provider) => {
|
|
103
|
+
let client: ReturnType<typeof createClient>;
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
client = createClient(provider);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should be able to return a list of products for recommendation - Related ', async () => {
|
|
110
|
+
const result = await client.productRecommendations.getRecommendations({
|
|
111
|
+
algorithm: 'related',
|
|
112
|
+
sourceProduct: {
|
|
113
|
+
key: testData.product.id,
|
|
114
|
+
},
|
|
115
|
+
numberOfRecommendations: 10,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!result.success) {
|
|
119
|
+
assert.fail(JSON.stringify(result.error));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
expect(result.value.length).toBeGreaterThan(0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should return an empty result for an unknown sku', async () => {
|
|
126
|
+
const result = await client.productRecommendations.getRecommendations({
|
|
127
|
+
algorithm: 'related',
|
|
128
|
+
sourceProduct: {
|
|
129
|
+
key: 'unknown-product-id',
|
|
130
|
+
},
|
|
131
|
+
numberOfRecommendations: 10,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (!result.success) {
|
|
135
|
+
assert.fail(JSON.stringify(result.error));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
expect(result.value.length).toBe(0);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactionary/source",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"dependencies": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
|
17
17
|
"@opentelemetry/sdk-trace-node": "^2.0.1",
|
|
18
18
|
"@upstash/redis": "^1.34.9",
|
|
19
|
-
"algoliasearch": "^5.
|
|
19
|
+
"algoliasearch": "^5.48.0",
|
|
20
20
|
"debug": "^4.4.3",
|
|
21
21
|
"dotenv": "^17.2.2",
|
|
22
22
|
"meilisearch": "^0.55.0",
|
|
@@ -3,9 +3,11 @@ import { AlgoliaProductSearchProvider } from "../providers/product-search.provid
|
|
|
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) {
|
|
@@ -16,6 +18,10 @@ export function withAlgoliaCapabilities<T extends AlgoliaCapabilities>(configura
|
|
|
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
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
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 {
|
|
13
|
+
liteClient,
|
|
14
|
+
type BoughtTogetherQuery,
|
|
15
|
+
type LookingSimilarQuery,
|
|
16
|
+
type RecommendationsResults,
|
|
17
|
+
type RecommendSearchParams,
|
|
18
|
+
type RelatedQuery,
|
|
19
|
+
type TrendingItemsQuery,
|
|
20
|
+
type LiteClient
|
|
21
|
+
} from 'algoliasearch/lite';
|
|
22
|
+
import type { AlgoliaConfiguration } from '../schema/configuration.schema.js';
|
|
23
|
+
import type { AlgoliaProductRecommendationIdentifier } from '../schema/product-recommendation.schema.js';
|
|
24
|
+
|
|
25
|
+
interface AlgoliaRecommendHit {
|
|
26
|
+
objectID: string;
|
|
27
|
+
sku?: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* AlgoliaProductRecommendationsProvider
|
|
33
|
+
*
|
|
34
|
+
* Provides product recommendations using Algolia's Recommend API.
|
|
35
|
+
* Supports frequentlyBoughtTogether, similar, related, and trendingInCategory algorithms.
|
|
36
|
+
*
|
|
37
|
+
* Note: This requires Algolia Recommend to be enabled and AI models to be trained.
|
|
38
|
+
* See: https://www.algolia.com/doc/guides/algolia-recommend/overview/
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
export class AlgoliaProductRecommendationsProvider extends ProductRecommendationsProvider {
|
|
45
|
+
protected config: AlgoliaConfiguration;
|
|
46
|
+
protected client: LiteClient;
|
|
47
|
+
|
|
48
|
+
constructor(config: AlgoliaConfiguration, cache: Cache, context: RequestContext) {
|
|
49
|
+
super(cache, context);
|
|
50
|
+
this.config = config;
|
|
51
|
+
this.client = liteClient(this.config.appId, this.config.apiKey);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
55
|
+
protected getRecommendationThreshold(_algorithm: string): number {
|
|
56
|
+
// Default threshold can be customized per algorithm if needed
|
|
57
|
+
// The parameter is currently unused but kept for future algorithm-specific threshold customization
|
|
58
|
+
return 10;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
protected getQueryParametersForRecommendations(algorithm: string): RecommendSearchParams {
|
|
62
|
+
return {
|
|
63
|
+
userToken: this.context.session.identityContext?.personalizationKey || 'anonymous',
|
|
64
|
+
analytics: true,
|
|
65
|
+
analyticsTags: ['reactionary', algorithm],
|
|
66
|
+
clickAnalytics: true
|
|
67
|
+
} satisfies RecommendSearchParams;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get frequently bought together recommendations using Algolia Recommend
|
|
72
|
+
*/
|
|
73
|
+
protected override async getFrequentlyBoughtTogetherRecommendations(
|
|
74
|
+
query: ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery
|
|
75
|
+
): Promise<ProductRecommendation[]> {
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// Note: Algolia's Recommend API requires setting up AI Recommend models
|
|
79
|
+
// This implementation uses the getRecommendations method from the recommend client
|
|
80
|
+
const response = await this.client.getRecommendations({
|
|
81
|
+
requests: [
|
|
82
|
+
{
|
|
83
|
+
indexName: this.config.indexName,
|
|
84
|
+
model: 'bought-together',
|
|
85
|
+
objectID: query.sourceProduct.key,
|
|
86
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
87
|
+
threshold: this.getRecommendationThreshold('bought-together'),
|
|
88
|
+
queryParameters: this.getQueryParametersForRecommendations('bought-together')
|
|
89
|
+
} satisfies BoughtTogetherQuery,
|
|
90
|
+
],
|
|
91
|
+
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const result = [];
|
|
95
|
+
if (response.results) {
|
|
96
|
+
for(const res of response.results) {
|
|
97
|
+
result.push(...this.parseRecommendation(res, query));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('Error fetching frequently bought together recommendations:', error);
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Get similar product recommendations using Algolia Recommend
|
|
109
|
+
*/
|
|
110
|
+
protected override async getSimilarProductsRecommendations(
|
|
111
|
+
query: ProductRecommendationAlgorithmSimilarProductsQuery
|
|
112
|
+
): Promise<ProductRecommendation[]> {
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const response = await this.client.getRecommendations({
|
|
116
|
+
requests: [
|
|
117
|
+
{
|
|
118
|
+
indexName: this.config.indexName,
|
|
119
|
+
model: 'looking-similar',
|
|
120
|
+
objectID: query.sourceProduct.key,
|
|
121
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
122
|
+
threshold: this.getRecommendationThreshold('looking-similar'),
|
|
123
|
+
queryParameters: this.getQueryParametersForRecommendations('looking-similar')
|
|
124
|
+
} satisfies LookingSimilarQuery
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const result = [];
|
|
129
|
+
if (response.results) {
|
|
130
|
+
for(const res of response.results) {
|
|
131
|
+
result.push(...this.parseRecommendation(res, query));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
console.error('Error fetching similar product recommendations:', error);
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get related product recommendations using Algolia Recommend
|
|
143
|
+
*/
|
|
144
|
+
protected override async getRelatedProductsRecommendations(
|
|
145
|
+
query: ProductRecommendationAlgorithmRelatedProductsQuery
|
|
146
|
+
): Promise<ProductRecommendation[]> {
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const response = await this.client.getRecommendations({
|
|
150
|
+
requests: [
|
|
151
|
+
{
|
|
152
|
+
indexName: this.config.indexName,
|
|
153
|
+
model: 'related-products',
|
|
154
|
+
objectID: query.sourceProduct.key,
|
|
155
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
156
|
+
threshold: this.getRecommendationThreshold('related-products'),
|
|
157
|
+
queryParameters: this.getQueryParametersForRecommendations('related-products')
|
|
158
|
+
} satisfies RelatedQuery,
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = [];
|
|
163
|
+
if (response.results) {
|
|
164
|
+
for(const res of response.results) {
|
|
165
|
+
result.push(...this.parseRecommendation(res, query));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('Error fetching related product recommendations:', error);
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Get trending in category recommendations using Algolia Recommend
|
|
177
|
+
*/
|
|
178
|
+
protected override async getTrendingInCategoryRecommendations(
|
|
179
|
+
query: ProductRecommendationAlgorithmTrendingInCategoryQuery
|
|
180
|
+
): Promise<ProductRecommendation[]> {
|
|
181
|
+
try {
|
|
182
|
+
const response = await this.client.getRecommendations({
|
|
183
|
+
requests: [
|
|
184
|
+
{
|
|
185
|
+
indexName: this.config.indexName,
|
|
186
|
+
model: 'trending-items',
|
|
187
|
+
facetName: 'categories',
|
|
188
|
+
facetValue: query.sourceCategory.key,
|
|
189
|
+
maxRecommendations: query.numberOfRecommendations,
|
|
190
|
+
threshold: this.getRecommendationThreshold('trending-items'),
|
|
191
|
+
queryParameters: this.getQueryParametersForRecommendations('trending-items')
|
|
192
|
+
} satisfies TrendingItemsQuery,
|
|
193
|
+
],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const result = [];
|
|
197
|
+
if (response.results) {
|
|
198
|
+
for(const res of response.results) {
|
|
199
|
+
result.push(...this.parseRecommendation(res, query));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error('Error fetching trending in category recommendations:', error);
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
protected parseRecommendation(res: RecommendationsResults, query: ProductRecommendationsQuery) {
|
|
211
|
+
const result = [];
|
|
212
|
+
for(const hit of res.hits as AlgoliaRecommendHit[]) {
|
|
213
|
+
const recommendationIdentifier = {
|
|
214
|
+
key: res.queryID || 'x',
|
|
215
|
+
algorithm: query.algorithm,
|
|
216
|
+
abTestID: res.abTestID,
|
|
217
|
+
abTestVariantID: res.abTestVariantID
|
|
218
|
+
} satisfies AlgoliaProductRecommendationIdentifier
|
|
219
|
+
const recommendation = this.parseSingle(hit, recommendationIdentifier)
|
|
220
|
+
result.push(recommendation);
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Maps Algolia recommendation results to ProductRecommendation format
|
|
227
|
+
*/
|
|
228
|
+
protected parseSingle(hit: AlgoliaRecommendHit, recommendationIdentifier: AlgoliaProductRecommendationIdentifier): ProductRecommendation {
|
|
229
|
+
return {
|
|
230
|
+
recommendationIdentifier,
|
|
231
|
+
product: {
|
|
232
|
+
key: hit.objectID,
|
|
233
|
+
},
|
|
234
|
+
} satisfies ProductRecommendation;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -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>;
|
|
@@ -12,6 +12,7 @@ import { MedusaOrderSearchProvider } from "../providers/order-search.provider.js
|
|
|
12
12
|
import { MedusaOrderProvider } from "../providers/order.provider.js";
|
|
13
13
|
import { MedusaPriceProvider } from "../providers/price.provider.js";
|
|
14
14
|
import { MedusaSearchProvider } from "../providers/product-search.provider.js";
|
|
15
|
+
import { MedusaProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
|
|
15
16
|
import { MedusaProductProvider } from "../providers/product.provider.js";
|
|
16
17
|
import { MedusaProfileProvider } from "../providers/profile.provider.js";
|
|
17
18
|
import { MedusaCapabilitiesSchema, type MedusaCapabilities } from "../schema/capabilities.schema.js";
|
|
@@ -23,6 +24,7 @@ export function withMedusaCapabilities<T extends MedusaCapabilities>(
|
|
|
23
24
|
capabilities: T
|
|
24
25
|
) {
|
|
25
26
|
return (cache: Cache, context: RequestContext): ClientFromCapabilities<T> => {
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
28
|
const client: any = {};
|
|
27
29
|
const config = MedusaConfigurationSchema.parse(configuration);
|
|
28
30
|
const caps = MedusaCapabilitiesSchema.parse(capabilities);
|
|
@@ -34,6 +36,10 @@ export function withMedusaCapabilities<T extends MedusaCapabilities>(
|
|
|
34
36
|
client.productSearch = new MedusaSearchProvider(configuration, cache, context, medusaApi);
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
if (caps.productRecommendations) {
|
|
40
|
+
client.productRecommendations = new MedusaProductRecommendationsProvider(configuration, cache, context, medusaApi);
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
if (caps.category) {
|
|
38
44
|
client.category = new MedusaCategoryProvider(configuration, cache, context, medusaApi);
|
|
39
45
|
}
|
|
@@ -10,4 +10,5 @@ export * from './providers/order.provider.js';
|
|
|
10
10
|
export * from './providers/order-search.provider.js';
|
|
11
11
|
export * from './providers/price.provider.js';
|
|
12
12
|
export * from './providers/product-search.provider.js';
|
|
13
|
+
export * from './providers/product-recommendations.provider.js';
|
|
13
14
|
export * from './providers/profile.provider.js';
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Cache,
|
|
3
|
+
ProductRecommendationsProvider,
|
|
4
|
+
type ProductRecommendation,
|
|
5
|
+
type ProductRecommendationsByCollectionQuery,
|
|
6
|
+
type RequestContext,
|
|
7
|
+
type Result,
|
|
8
|
+
success, error,
|
|
9
|
+
NotFoundErrorSchema,
|
|
10
|
+
type NotFoundError,
|
|
11
|
+
errorToString,
|
|
12
|
+
} from '@reactionary/core';
|
|
13
|
+
import createDebug from 'debug';
|
|
14
|
+
import type { MedusaAPI } from '../core/client.js';
|
|
15
|
+
import type { MedusaConfiguration } from '../schema/configuration.schema.js';
|
|
16
|
+
|
|
17
|
+
const debug = createDebug('reactionary:medusa:product-recommendations');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* MedusaProductRecommendationsProvider
|
|
21
|
+
*
|
|
22
|
+
* Provides product recommendations using Medusa's collection-based product grouping.
|
|
23
|
+
* Only overrides the getCollection method to fetch products from Medusa collections.
|
|
24
|
+
*
|
|
25
|
+
* Note: This implementation leverages Medusa's product collections feature to provide
|
|
26
|
+
* curated product recommendations. Collections are typically manually managed or
|
|
27
|
+
* created through Medusa's admin panel or API.
|
|
28
|
+
*/
|
|
29
|
+
export class MedusaProductRecommendationsProvider extends ProductRecommendationsProvider {
|
|
30
|
+
protected config: MedusaConfiguration;
|
|
31
|
+
protected medusaApi: MedusaAPI;
|
|
32
|
+
|
|
33
|
+
constructor(config: MedusaConfiguration, cache: Cache, context: RequestContext, medusaApi: MedusaAPI) {
|
|
34
|
+
super(cache, context);
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.medusaApi = medusaApi;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get product recommendations from a Medusa collection
|
|
41
|
+
*
|
|
42
|
+
* This method fetches products from a specific Medusa collection by name or handle.
|
|
43
|
+
* It's useful for displaying curated product lists like "Featured Products",
|
|
44
|
+
* "New Arrivals", "Best Sellers", etc.
|
|
45
|
+
*/
|
|
46
|
+
public override async getCollection(
|
|
47
|
+
query: ProductRecommendationsByCollectionQuery
|
|
48
|
+
): Promise<Result<ProductRecommendation[]>> {
|
|
49
|
+
const client = await this.medusaApi.getClient();
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (debug.enabled) {
|
|
53
|
+
debug(`Fetching collection: ${query.collectionName}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// First, find the collection by handle/name
|
|
57
|
+
const collectionsResponse = await client.store.collection.list({
|
|
58
|
+
handle: query.collectionName,
|
|
59
|
+
limit: 1,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!collectionsResponse.collections || collectionsResponse.collections.length === 0) {
|
|
63
|
+
if (debug.enabled) {
|
|
64
|
+
debug(`Collection not found: ${query.collectionName}`);
|
|
65
|
+
}
|
|
66
|
+
return error<NotFoundError>({
|
|
67
|
+
type: 'NotFound',
|
|
68
|
+
identifier: query.collectionName
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const collection = collectionsResponse.collections[0];
|
|
73
|
+
|
|
74
|
+
if (debug.enabled) {
|
|
75
|
+
debug(`Found collection: ${collection.title} (${collection.id})`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fetch products from the collection
|
|
79
|
+
const productsResponse = await client.store.product.list({
|
|
80
|
+
collection_id: [collection.id],
|
|
81
|
+
limit: query.numberOfRecommendations,
|
|
82
|
+
fields: '+variants.id,+variants.sku',
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (debug.enabled) {
|
|
86
|
+
debug(`Found ${productsResponse.products.length} products in collection`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Map products to recommendations
|
|
90
|
+
const recommendations: ProductRecommendation[] = [];
|
|
91
|
+
|
|
92
|
+
for (const product of productsResponse.products) {
|
|
93
|
+
recommendations.push({
|
|
94
|
+
recommendationIdentifier: {
|
|
95
|
+
key: `${collection.id}_${product.id}`,
|
|
96
|
+
algorithm: 'collection',
|
|
97
|
+
},
|
|
98
|
+
product: {
|
|
99
|
+
key: product.id
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (debug.enabled) {
|
|
105
|
+
debug(`Returning ${recommendations.length} recommendations`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return success(recommendations);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (debug.enabled) {
|
|
111
|
+
debug(`Error fetching collection recommendations: %O`, error);
|
|
112
|
+
}
|
|
113
|
+
console.error('Error fetching collection recommendations:', error);
|
|
114
|
+
return success([]);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import type { Cache, ClientFromCapabilities, RequestContext } from "@reactionary/core";
|
|
2
2
|
import { MeilisearchSearchProvider } from "../providers/product-search.provider.js";
|
|
3
|
+
import { MeilisearchProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
|
|
3
4
|
import { MeilisearchOrderSearchProvider } from "../providers/order-search.provider.js";
|
|
4
5
|
import type { MeilisearchCapabilities } from "../schema/capabilities.schema.js";
|
|
5
6
|
import type { MeilisearchConfiguration } from "../schema/configuration.schema.js";
|
|
6
7
|
|
|
7
8
|
export function withMeilisearchCapabilities<T extends MeilisearchCapabilities>(configuration: MeilisearchConfiguration, 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
14
|
client.productSearch = new MeilisearchSearchProvider(configuration, cache, context);
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
if (capabilities.productRecommendations) {
|
|
18
|
+
client.productRecommendations = new MeilisearchProductRecommendationsProvider(configuration, cache, context);
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
if (capabilities.orderSearch) {
|
|
16
22
|
client.orderSearch = new MeilisearchOrderSearchProvider(configuration, cache, context);
|
|
17
23
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * from './core/initialize.js';
|
|
2
2
|
export * from './providers/product-search.provider.js';
|
|
3
|
+
export * from './providers/product-recommendations.provider.js';
|
|
3
4
|
export * from './providers/order-search.provider.js';
|
|
4
5
|
|
|
5
6
|
export * from './schema/configuration.schema.js';
|