@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.
Files changed (33) hide show
  1. package/core/src/client/client-builder.ts +8 -0
  2. package/core/src/client/client.ts +4 -0
  3. package/core/src/providers/index.ts +2 -0
  4. package/core/src/providers/product-associations.provider.ts +49 -0
  5. package/core/src/providers/product-recommendations.provider.ts +150 -0
  6. package/core/src/schemas/capabilities.schema.ts +2 -0
  7. package/core/src/schemas/models/identifiers.model.ts +8 -0
  8. package/core/src/schemas/models/index.ts +1 -0
  9. package/core/src/schemas/models/product-recommendations.model.ts +10 -0
  10. package/core/src/schemas/queries/index.ts +2 -0
  11. package/core/src/schemas/queries/product-associations.query.ts +23 -0
  12. package/core/src/schemas/queries/product-recommendations.query.ts +72 -0
  13. package/documentation/docs/7-marketing.md +95 -0
  14. package/examples/node/package.json +6 -6
  15. package/examples/node/src/capabilities/product-recommendations.spec.ts +140 -0
  16. package/examples/node/src/utils.ts +3 -0
  17. package/package.json +2 -2
  18. package/providers/algolia/package.json +1 -1
  19. package/providers/algolia/src/core/initialize.ts +6 -0
  20. package/providers/algolia/src/index.ts +1 -0
  21. package/providers/algolia/src/providers/index.ts +2 -1
  22. package/providers/algolia/src/providers/product-recommendations.provider.ts +236 -0
  23. package/providers/algolia/src/schema/capabilities.schema.ts +2 -1
  24. package/providers/algolia/src/schema/product-recommendation.schema.ts +9 -0
  25. package/providers/medusa/src/core/initialize.ts +6 -0
  26. package/providers/medusa/src/index.ts +1 -0
  27. package/providers/medusa/src/providers/product-recommendations.provider.ts +117 -0
  28. package/providers/medusa/src/schema/capabilities.schema.ts +1 -0
  29. package/providers/meilisearch/src/core/initialize.ts +6 -0
  30. package/providers/meilisearch/src/index.ts +1 -0
  31. package/providers/meilisearch/src/providers/index.ts +1 -0
  32. package/providers/meilisearch/src/providers/product-recommendations.provider.ts +89 -0
  33. 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.2",
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.23.4",
19
+ "algoliasearch": "^5.48.0",
20
20
  "debug": "^4.4.3",
21
21
  "dotenv": "^17.2.2",
22
22
  "meilisearch": "^0.55.0",
@@ -5,7 +5,7 @@
5
5
  "types": "src/index.d.ts",
6
6
  "dependencies": {
7
7
  "@reactionary/core": "0.0.1",
8
- "algoliasearch": "^5.23.4",
8
+ "algoliasearch": "^5.48.0",
9
9
  "zod": "4.1.9"
10
10
  },
11
11
  "type": "module",
@@ -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
  }
@@ -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
 
4
5
  export * from './schema/configuration.schema.js';
5
6
  export * from './schema/capabilities.schema.js';
@@ -1,2 +1,3 @@
1
1
  export * from './analytics.provider.js';
2
- export * from './product-search.provider.js';
2
+ export * from './product-search.provider.js';
3
+ export * from './product-recommendations.provider.js';
@@ -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
+ }
@@ -3,6 +3,7 @@ import type { z } from 'zod';
3
3
 
4
4
  export const MedusaCapabilitiesSchema = CapabilitiesSchema.pick({
5
5
  productSearch: true,
6
+ productRecommendations: true,
6
7
  cart: true,
7
8
  checkout: true,
8
9
  category: true,
@@ -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';
@@ -1 +1,2 @@
1
1
  export * from './product-search.provider.js';
2
+ export * from './product-recommendations.provider.js';