@reactionary/source 0.3.2 → 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.
Files changed (32) 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 +96 -0
  16. package/examples/node/src/utils.ts +3 -0
  17. package/package.json +1 -1
  18. package/providers/algolia/src/core/initialize.ts +6 -0
  19. package/providers/algolia/src/index.ts +1 -0
  20. package/providers/algolia/src/providers/index.ts +2 -1
  21. package/providers/algolia/src/providers/product-recommendations.provider.ts +234 -0
  22. package/providers/algolia/src/schema/capabilities.schema.ts +2 -1
  23. package/providers/algolia/src/schema/product-recommendation.schema.ts +9 -0
  24. package/providers/medusa/src/core/initialize.ts +6 -0
  25. package/providers/medusa/src/index.ts +1 -0
  26. package/providers/medusa/src/providers/product-recommendations.provider.ts +117 -0
  27. package/providers/medusa/src/schema/capabilities.schema.ts +1 -0
  28. package/providers/meilisearch/src/core/initialize.ts +6 -0
  29. package/providers/meilisearch/src/index.ts +1 -0
  30. package/providers/meilisearch/src/providers/index.ts +1 -0
  31. package/providers/meilisearch/src/providers/product-recommendations.provider.ts +89 -0
  32. package/providers/meilisearch/src/schema/capabilities.schema.ts +1 -0
@@ -6,6 +6,7 @@ import {
6
6
  RequestContextSchema,
7
7
  type RequestContext,
8
8
  } from '../schemas/session.schema.js';
9
+ import { MulticastProductRecommendationsProvider, type ProductRecommendationsProvider } from '../providers/product-recommendations.provider.js';
9
10
 
10
11
  type CapabilityFactory<T> = (cache: Cache, context: RequestContext) => T;
11
12
 
@@ -52,6 +53,8 @@ export class ClientBuilder<TClient = Client> {
52
53
 
53
54
  const mergedAnalytics: AnalyticsProvider[] = [];
54
55
 
56
+ const mergedProductRecommendationsProviders: ProductRecommendationsProvider[] = [];
57
+
55
58
  for (const factory of this.factories) {
56
59
  const provider = factory(sharedCache, this.context);
57
60
  client = {
@@ -62,12 +65,17 @@ export class ClientBuilder<TClient = Client> {
62
65
  if (provider.analytics) {
63
66
  mergedAnalytics.push(provider.analytics);
64
67
  }
68
+
69
+ if (provider.productRecommendations) {
70
+ mergedProductRecommendationsProviders.push(provider.productRecommendations);
71
+ }
65
72
  }
66
73
 
67
74
  // Add cache to complete the client
68
75
  const completeClient = {
69
76
  ...client,
70
77
  analytics: new MulticastAnalyticsProvider(sharedCache, this.context, mergedAnalytics),
78
+ productRecommendations: new MulticastProductRecommendationsProvider(sharedCache, this.context, mergedProductRecommendationsProviders),
71
79
  cache: sharedCache,
72
80
  } as TClient & { cache: Cache };
73
81
 
@@ -8,10 +8,14 @@ import type { Cache } from "../cache/cache.interface.js";
8
8
  import type { CategoryProvider } from "../providers/category.provider.js";
9
9
  import type { AnalyticsProvider, CheckoutProvider, OrderProvider, ProfileProvider, StoreProvider } from "../providers/index.js";
10
10
  import type { OrderSearchProvider } from "../providers/order-search.provider.js";
11
+ import type { ProductRecommendationsProvider } from "../providers/product-recommendations.provider.js";
12
+ import type { ProductAssociationsProvider } from "../providers/product-associations.provider.js";
11
13
 
12
14
  export interface Client {
13
15
  product: ProductProvider,
14
16
  productSearch: ProductSearchProvider,
17
+ productRecommendations: ProductRecommendationsProvider,
18
+ productAssociations: ProductAssociationsProvider,
15
19
  identity: IdentityProvider,
16
20
  cache: Cache,
17
21
  cart: CartProvider,
@@ -9,6 +9,8 @@ export * from './price.provider.js';
9
9
  export * from './product.provider.js';
10
10
  export * from './profile.provider.js';
11
11
  export * from './product-search.provider.js';
12
+ export * from './product-recommendations.provider.js';
13
+ export * from './product-associations.provider.js';
12
14
  export * from './store.provider.js';
13
15
  export * from './order.provider.js'
14
16
  export * from './order-search.provider.js'
@@ -0,0 +1,49 @@
1
+ import type { ProductIdentifier, ProductVariantIdentifier } from "../schemas/index.js";
2
+ import type { ProductAssociationsGetAccessoriesQuery, ProductAssociationsGetSparepartsQuery, ProductAssociationsGetReplacementsQuery } from "../schemas/queries/product-associations.query.js";
3
+ import { BaseProvider } from "./base.provider.js";
4
+
5
+
6
+ /**
7
+ * The product association provider is responsible for providing evidence based associations between products, such as
8
+ * accessories, spareparts, and replacements. These associations are typically used to provide recommendations to customers on the product detail page, but can also be used in other contexts such as the cart or post-purchase, but
9
+ * do not carry any personalization concept to them.
10
+ */
11
+ export abstract class ProductAssociationsProvider extends BaseProvider {
12
+
13
+ /**
14
+ * Returns a list of product identifiers which are accessories to the given product.
15
+ * Accessories in are products in their own right, but are commonly purchased alongside or recommended as complementary to the main product. Examples of accessories include:
16
+ * - A phone case for a smartphone
17
+ * - A camera bag for a camera
18
+ *
19
+ *
20
+ * Usecase:
21
+ * - PDP: Accessories for this product
22
+ */
23
+ public abstract getAccessories(query: ProductAssociationsGetAccessoriesQuery): Promise<ProductVariantIdentifier[]>;
24
+
25
+ /**
26
+ * Returns a list of product identifiers which are spareparts to the given product.
27
+ * Spareparts are products which are necessary for the use of the main product, but are not typically purchased alongside it. Examples of spareparts include:
28
+ *
29
+ * Usecase:
30
+ * - PDP: Accessories for this product
31
+ */
32
+ public abstract getSpareparts(query: ProductAssociationsGetSparepartsQuery): Promise<ProductVariantIdentifier[]>;
33
+
34
+
35
+ /**
36
+ * This product is replaced by these equivalent or newer products
37
+ * @param query
38
+ */
39
+ public abstract getReplacements(query: ProductAssociationsGetReplacementsQuery): Promise<ProductVariantIdentifier[]>;
40
+
41
+
42
+ getResourceName(): string {
43
+ return 'product-associations';
44
+ }
45
+
46
+
47
+
48
+
49
+ }
@@ -0,0 +1,150 @@
1
+ import type { Cache } from '../cache/cache.interface.js';
2
+ import { Reactionary } from "../decorators/reactionary.decorator.js";
3
+ import { success, type ProductRecommendation, type ProductRecommendationsByCollectionQuery, type RequestContext, type Result } from "../schemas/index.js";
4
+ import { ProductRecommendationsQuerySchema, type ProductRecommendationAlgorithmAlsoViewedProductsQuery, type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery, type ProductRecommendationAlgorithmPopuplarProductsQuery, type ProductRecommendationAlgorithmRelatedProductsQuery, type ProductRecommendationAlgorithmSimilarProductsQuery, type ProductRecommendationAlgorithmTopPicksProductsQuery, type ProductRecommendationAlgorithmTrendingInCategoryQuery, type ProductRecommendationsQuery } from "../schemas/queries/product-recommendations.query.js";
5
+ import { BaseProvider } from "./base.provider.js";
6
+
7
+ export abstract class ProductRecommendationsProvider extends BaseProvider {
8
+
9
+ /**
10
+ * returns a list of recommended products, based on the selected algorithm and the provided query parameters. The recommendations should be relevant to the product specified in the query, and can be personalized based on the customer segments or contexts provided. The provider should return a list of product variant identifiers that are recommended for the given product, which can then be used to fetch the full product details from the product provider if needed.
11
+ * *
12
+ * Usecase:
13
+ * - PDP - "Customers who viewed this product also viewed"
14
+ * - Cart - "You might also like"
15
+ * - Post-purchase - "Customers who bought this product also bought"
16
+ * - Article page: "Products related to the product mentioned in this article"
17
+ * @param query
18
+ */
19
+ public async getRecommendations(query: ProductRecommendationsQuery): Promise<Result<ProductRecommendation[]>> {
20
+ if (query.algorithm === 'frequentlyBoughtTogether') {
21
+ return success(await this.getFrequentlyBoughtTogetherRecommendations(query));
22
+ }
23
+
24
+ if (query.algorithm === 'similar') {
25
+ return success(await this.getSimilarProductsRecommendations(query));
26
+ }
27
+
28
+ if (query.algorithm === 'related') {
29
+ return success(await this.getRelatedProductsRecommendations(query));
30
+ }
31
+
32
+ if (query.algorithm === 'trendingInCategory') {
33
+ return success(await this.getTrendingInCategoryRecommendations(query));
34
+ }
35
+
36
+ if (query.algorithm === 'popular') {
37
+ return success(await this.getPopularProductsRecommendations(query));
38
+ }
39
+
40
+ if (query.algorithm === 'topPicks') {
41
+ return success(await this.getTopPicksProductsRecommendations(query));
42
+ }
43
+
44
+ if (query.algorithm === 'alsoViewed') {
45
+ return success(await this.getAlsoViewedProductsRecommendations(query));
46
+ }
47
+ return success([]);
48
+ }
49
+
50
+ public async getCollection(query: ProductRecommendationsByCollectionQuery): Promise<Result<ProductRecommendation[]>> {
51
+ return success([]);
52
+ }
53
+
54
+ protected async getFrequentlyBoughtTogetherRecommendations(query: ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery): Promise<ProductRecommendation[]> {
55
+ return [];
56
+ }
57
+
58
+ protected async getSimilarProductsRecommendations(query: ProductRecommendationAlgorithmSimilarProductsQuery): Promise<ProductRecommendation[]> {
59
+ return [];
60
+ }
61
+
62
+ protected async getTrendingInCategoryRecommendations(query: ProductRecommendationAlgorithmTrendingInCategoryQuery): Promise<ProductRecommendation[]> {
63
+ return [];
64
+ }
65
+
66
+ protected async getRelatedProductsRecommendations(query: ProductRecommendationAlgorithmRelatedProductsQuery): Promise<ProductRecommendation[]> {
67
+ return [];
68
+ }
69
+
70
+ protected async getPopularProductsRecommendations(query: ProductRecommendationAlgorithmPopuplarProductsQuery): Promise<ProductRecommendation[]> {
71
+ return [];
72
+ }
73
+
74
+ protected async getTopPicksProductsRecommendations(query: ProductRecommendationAlgorithmTopPicksProductsQuery): Promise<ProductRecommendation[]> {
75
+ return [];
76
+ }
77
+
78
+ protected async getAlsoViewedProductsRecommendations(query: ProductRecommendationAlgorithmAlsoViewedProductsQuery): Promise<ProductRecommendation[]> {
79
+ return [];
80
+ }
81
+
82
+
83
+ protected override getResourceName(): string {
84
+ return 'product-recommendations';
85
+ }
86
+
87
+
88
+ }
89
+
90
+
91
+
92
+ export class MulticastProductRecommendationsProvider extends ProductRecommendationsProvider {
93
+ protected providers: Array<ProductRecommendationsProvider>;
94
+
95
+ constructor(
96
+ cache: Cache,
97
+ requestContext: RequestContext,
98
+ providers: Array<ProductRecommendationsProvider>
99
+ ) {
100
+ super(cache, requestContext);
101
+
102
+ this.providers = providers;
103
+ }
104
+
105
+ @Reactionary({
106
+ inputSchema: ProductRecommendationsQuerySchema,
107
+ })
108
+ public override async getRecommendations(query: ProductRecommendationsQuery): Promise<Result<ProductRecommendation[]>> {
109
+ const output = [];
110
+ for (const provider of this.providers) {
111
+ const providerOutput = await provider.getRecommendations(query);
112
+ if (providerOutput.success) {
113
+
114
+ output.push(...providerOutput.value);
115
+ } else {
116
+ // For other types of errors, we might want to log them or handle them differently
117
+ console.error(`Error from provider ${provider.constructor.name}:`, providerOutput.error);
118
+ return providerOutput
119
+ }
120
+ if (output.length >= query.numberOfRecommendations) {
121
+ break;
122
+ }
123
+ }
124
+ return success(output.slice(0, query.numberOfRecommendations));
125
+ }
126
+
127
+ public override async getCollection(query: ProductRecommendationsByCollectionQuery): Promise<Result<ProductRecommendation[]>> {
128
+ const output = [];
129
+ for (const provider of this.providers) {
130
+ const providerOutput = await provider.getCollection(query);
131
+ if (providerOutput.success) {
132
+ output.push(...providerOutput.value);
133
+ } else {
134
+ if (providerOutput.error.type === 'NotFound') {
135
+ // If the error is a NotFound error, we can ignore it and continue to the next provider
136
+ continue;
137
+ } else {
138
+ // For other types of errors, we might want to log them or handle them differently
139
+ console.error(`Error from provider ${provider.constructor.name}:`, providerOutput.error);
140
+ return providerOutput;
141
+ }
142
+ }
143
+ if (output.length >= query.numberOfRecommendations) {
144
+ break;
145
+ }
146
+ }
147
+ return success(output.slice(0, query.numberOfRecommendations));
148
+ }
149
+
150
+ }
@@ -5,6 +5,8 @@ import type { Client } from '../client/client.js';
5
5
  export const CapabilitiesSchema = z.looseObject({
6
6
  product: z.boolean(),
7
7
  productSearch: z.boolean(),
8
+ productAssociations: z.boolean(),
9
+ productRecommendations: z.boolean(),
8
10
  analytics: z.boolean(),
9
11
  identity: z.boolean(),
10
12
  cart: z.boolean(),
@@ -118,6 +118,11 @@ export const PickupPointIdentifierSchema = z.looseObject({
118
118
  key: z.string(),
119
119
  });
120
120
 
121
+ export const ProductRecommendationIdentifierSchema = z.looseObject({
122
+ key: z.string(),
123
+ algorithm: z.string(),
124
+ });
125
+
121
126
 
122
127
  export const ProductSearchIdentifierSchema = z.looseObject({
123
128
  term: z.string().describe('The search term used to find products.'),
@@ -141,6 +146,7 @@ export const OrderSearchIdentifierSchema = z.looseObject({
141
146
  paginationOptions: PaginationOptionsSchema.describe('Pagination options for the search results.'),
142
147
  });
143
148
 
149
+
144
150
  export type OrderSearchIdentifier = InferType<typeof OrderSearchIdentifierSchema>;
145
151
  export type ProductIdentifier = InferType<typeof ProductIdentifierSchema>;
146
152
  export type ProductVariantIdentifier = InferType<typeof ProductVariantIdentifierSchema>;
@@ -178,6 +184,7 @@ export type ProductOptionIdentifier = InferType<typeof ProductOptionIdentifierSc
178
184
  export type ProductOptionValueIdentifier = InferType<typeof ProductOptionValueIdentifierSchema>;
179
185
  export type ProductAttributeIdentifier = InferType<typeof ProductAttributeIdentifierSchema>;
180
186
  export type ProductAttributeValueIdentifier = InferType<typeof ProductAttributeValueIdentifierSchema>;
187
+ export type ProductRecommendationIdentifier = InferType<typeof ProductRecommendationIdentifierSchema>;
181
188
 
182
189
  export type IdentifierType =
183
190
  | ProductIdentifier
@@ -191,6 +198,7 @@ export type IdentifierType =
191
198
  | CategoryIdentifier
192
199
  | WebStoreIdentifier
193
200
  | InventoryIdentifier
201
+ | ProductRecommendationIdentifier
194
202
  | FulfillmentCenterIdentifier
195
203
  | IdentityIdentifier
196
204
  | ShippingMethodIdentifier
@@ -18,3 +18,4 @@ export * from './cost.model.js';
18
18
  export * from './checkout.model.js';
19
19
  export * from './payment.model.js';
20
20
  export * from './order-search.model.js'
21
+ export * from './product-recommendations.model.js';
@@ -0,0 +1,10 @@
1
+ import z from "zod";
2
+ import { ProductIdentifierSchema, ProductRecommendationIdentifierSchema, ProductVariantIdentifierSchema } from "./identifiers.model.js";
3
+
4
+ export const ProductRecommendationSchema = z.looseObject({
5
+ recommendationIdentifier: ProductRecommendationIdentifierSchema.describe('The identifier for the product recommendation, which includes a key and an algorithm and any other vendor specific/instance specific data '),
6
+ product: ProductIdentifierSchema.describe('The identifier for the recommended product.'),
7
+ });
8
+
9
+
10
+ export type ProductRecommendation = z.infer<typeof ProductRecommendationSchema>;
@@ -12,3 +12,5 @@ export * from './store.query.js';
12
12
  export * from './order.query.js';
13
13
  export * from './checkout.query.js';
14
14
  export * from './order-search.query.js'
15
+ export * from './product-recommendations.query.js';
16
+ export * from './product-associations.query.js';
@@ -0,0 +1,23 @@
1
+ import z from "zod";
2
+ import { BaseQuerySchema } from "./base.query.js";
3
+ import { ProductIdentifierSchema, ProductVariantIdentifierSchema } from "../models/identifiers.model.js";
4
+ import { CartItemSchema } from "../models/cart.model.js";
5
+
6
+ export const ProductAssociationsGetAccessoriesQuerySchema = BaseQuerySchema.extend({
7
+ forProductVariant: ProductVariantIdentifierSchema.describe('The product variant identifier for which to get accessory recommendations. The provider should return recommendations that are relevant to this product, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
8
+ numberOfAccessories: z.number().min(1).max(12).describe('The number of accessory recommendations requested. The provider may return fewer than this number, but should not return more.'),
9
+ });
10
+
11
+ export const ProductAssociationsGetSparepartsQuerySchema = BaseQuerySchema.extend({
12
+ forProductVariant: ProductVariantIdentifierSchema.describe('The product variant identifier for which to get similar item recommendations. The provider should return recommendations that are relevant to this product, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
13
+ });
14
+
15
+ export const ProductAssociationsGetReplacementsQuerySchema = BaseQuerySchema.extend({
16
+ forProductVariant: ProductVariantIdentifierSchema.describe('The product variant identifier for which to get replacement recommendations. The provider should return recommendations that are relevant to this product, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
17
+ });
18
+
19
+
20
+
21
+ export type ProductAssociationsGetAccessoriesQuery = z.infer<typeof ProductAssociationsGetAccessoriesQuerySchema>;
22
+ export type ProductAssociationsGetSparepartsQuery = z.infer<typeof ProductAssociationsGetSparepartsQuerySchema>;
23
+ export type ProductAssociationsGetReplacementsQuery = z.infer<typeof ProductAssociationsGetReplacementsQuerySchema>;
@@ -0,0 +1,72 @@
1
+ import z from "zod";
2
+ import type { InferType } from "../../zod-utils.js";
3
+ import { CategoryIdentifierSchema, ProductIdentifierSchema } from "../models/identifiers.model.js";
4
+ import { BaseQuerySchema } from "./base.query.js";
5
+
6
+ export const ProductRecommendationBaseQuerySchema = BaseQuerySchema.extend({
7
+ numberOfRecommendations: z.number().min(1).max(12).describe('The number of recommendations requested. The provider may return fewer than this number, but should not return more.'),
8
+ labels: z.array(z.string()).optional().describe('The customer segments, quirks, chirps or other labels to which the recommendations can optimize themselves to be relevant. This can be used by the provider to personalize the recommendations based on the preferences and behaviors of users in these segments.'),
9
+ });
10
+
11
+ export const ProductRecommendationsByCollectionQuerySchema = ProductRecommendationBaseQuerySchema.extend({
12
+ collectionName: z.string().describe('The name of the collection for which to get product recommendations. This is to access either manually curated lists, or interface marketing rules engines that define zones by name'),
13
+ sourceProduct: z.array(ProductIdentifierSchema).optional().describe('The products on screen or in the context you are asking for the recommendations. Could be all the variants from the current cart (resolved into their products), or just the variant of the PDP, or the 4 first products of a category.'),
14
+ sourceCategory: CategoryIdentifierSchema.optional().describe('The category identifier to use as a seed for the recommendations. The provider should return recommendations that are relevant to this category, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences. This is optional, as the collection may already be curated to be relevant to a specific product or category, but it can be used by the provider to further personalize the recommendations based on the preferences and behaviors of users who have interacted with this category.'),
15
+ });
16
+
17
+ export const ProductRecommendationProductBasedBaseQuerySchema = ProductRecommendationBaseQuerySchema.extend({
18
+ sourceProduct: ProductIdentifierSchema.describe('The product identifiers for which to get recommendations. The provider should return recommendations that are relevant to these products, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
19
+ });
20
+
21
+ export const ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
22
+ algorithm: z.literal('frequentlyBoughtTogether').describe('The provider should return recommendations based on products that are frequently bought together with the source products. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.'),
23
+ });
24
+
25
+ export const ProductRecommendationAlgorithmSimilarProductsQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
26
+ algorithm: z.literal('similar').describe('The provider should return recommendations based on products that are similar to the source products either visually or data wise'),
27
+ });
28
+
29
+ export const ProductRecommendationAlgorithmRelatedProductsQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
30
+ algorithm: z.literal('related').describe('The provider should return recommendations based on products that are related to the source products. '),
31
+ });
32
+
33
+ export const ProductRecommendationAlgorithmTrendingInCategoryQuerySchema = ProductRecommendationBaseQuerySchema.extend({
34
+ algorithm: z.literal('trendingInCategory').describe('The provider should return recommendations based on products that are trending in the specified category. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.'),
35
+ sourceCategory: CategoryIdentifierSchema.describe('The category identifier for which to get trending product recommendations. The provider should return recommendations that are relevant to this category, e.g., products that are frequently bought together, products that are similar in style or category, or products that are popular among users with similar preferences.'),
36
+ });
37
+
38
+
39
+ // unsure if we need both Popular and TopPicks, as they are quite similar. Maybe we can merge them into one algorithm with a parameter to specify the type of popularity? For now, I'll keep them separate for clarity.
40
+ export const ProductRecommendationAlgorithmPopuplarProductsQuerySchema = ProductRecommendationBaseQuerySchema.extend({
41
+ algorithm: z.literal('popular').describe('The provider should return recommendations based on products that are popular among users. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.'),
42
+ });
43
+
44
+ export const ProductRecommendationAlgorithmTopPicksProductsQuerySchema = ProductRecommendationBaseQuerySchema.extend({
45
+ algorithm: z.literal('topPicks').describe('The provider should return recommendations based on products that are top picks among users. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.') ,
46
+ });
47
+
48
+ export const ProductRecommendationAlgorithmAlsoViewedProductsQuerySchema = ProductRecommendationProductBasedBaseQuerySchema.extend({
49
+ algorithm: z.literal('alsoViewed').describe('The provider should return recommendations based on products that are also viewed by users. The provider should leverage the Request Context to personalize the recommendations as much as possible, taking into account factors such as the user\'s browsing history, purchase history, and demographic information.') ,
50
+ });
51
+
52
+
53
+
54
+ export const ProductRecommendationsQuerySchema = z.discriminatedUnion('algorithm', [
55
+ ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuerySchema,
56
+ ProductRecommendationAlgorithmTrendingInCategoryQuerySchema,
57
+ ProductRecommendationAlgorithmSimilarProductsQuerySchema,
58
+ ProductRecommendationAlgorithmRelatedProductsQuerySchema,
59
+ ProductRecommendationAlgorithmPopuplarProductsQuerySchema,
60
+ ProductRecommendationAlgorithmTopPicksProductsQuerySchema,
61
+ ProductRecommendationAlgorithmAlsoViewedProductsQuerySchema
62
+ ]);
63
+
64
+ export type ProductRecommendationsQuery = InferType<typeof ProductRecommendationsQuerySchema>;
65
+ export type ProductRecommendationsByCollectionQuery = InferType<typeof ProductRecommendationsByCollectionQuerySchema>;
66
+ export type ProductRecommendationAlgorithmTopPicksProductsQuery = InferType<typeof ProductRecommendationAlgorithmTopPicksProductsQuerySchema>;
67
+ export type ProductRecommendationAlgorithmPopuplarProductsQuery = InferType<typeof ProductRecommendationAlgorithmPopuplarProductsQuerySchema>;
68
+ export type ProductRecommendationAlgorithmTrendingInCategoryQuery = InferType<typeof ProductRecommendationAlgorithmTrendingInCategoryQuerySchema>;
69
+ export type ProductRecommendationAlgorithmRelatedProductsQuery = InferType<typeof ProductRecommendationAlgorithmRelatedProductsQuerySchema>;
70
+ export type ProductRecommendationAlgorithmSimilarProductsQuery= InferType<typeof ProductRecommendationAlgorithmSimilarProductsQuerySchema>;
71
+ export type ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuery = InferType<typeof ProductRecommendationAlgorithmFrequentlyBoughtTogetherQuerySchema>;
72
+ export type ProductRecommendationAlgorithmAlsoViewedProductsQuery = InferType<typeof ProductRecommendationAlgorithmAlsoViewedProductsQuerySchema>;
@@ -1,3 +1,98 @@
1
1
  # Adding the personal touch
2
2
 
3
+ Reactionary supports many mechanisms by which to make the customer journey unique to a customer. The main mechanism is via the `ProductRecommendationsProvider` which offers 7 unique ways to find some relevant products to display for the user.
4
+
5
+ It is assumed, that any basic-direct-assign logic (ie editor has picked 4 products to show) is already handled at the CMS level. The Reactionary provider is for the usecase where you want a dynamic result back, based on the users behavior, tags, or whatever you have.
6
+
7
+ The supported algorithms are
8
+
9
+ ```
10
+ - Frequently-Bought-Together (Product based)
11
+ - Trending-In-Category (Category based)
12
+ - Similar (Product)
13
+ - Related-Items (Product)
14
+ - Popular-Products (User)
15
+ - Top-Picks-For-You (User)
16
+ - Also-Viewed (Product, User)
17
+ ```
18
+
19
+ Reactionary supports having multiple providers of product recommendations, each of which will be asked in order, until the result is fully populated.
20
+ Every provider might not support every algorithm though.
21
+
22
+ But, the point of this is, that you can either hardcode something on the PDP
23
+
24
+ ```ts
25
+ const recommendations = client.productRecommendations.getRecommendations({
26
+ algorithm: 'similar',
27
+ numberOfRecommendations: 8,
28
+ labels: [
29
+ (
30
+ isLoggedIn: 'Registered' : 'Guest',
31
+ registeredThisSession? 'FirstSession': 'ReturningCustomer',
32
+ customerSegments.map(x => x.name),
33
+ new Date().getHour() > 12? "Afternoon": "Morning",
34
+ getTemperatureAround(context.clientIp) < 20: 'Cold': 'Warm'
35
+ )
36
+ ]
37
+ });
38
+
39
+ if (recommendations.success) {
40
+ // we can now resolve them
41
+ const allProducts = Promise.all(
42
+ recommendations.value.products.map(x => client.product.getByID(x)).map(x => x.successs? x.value : null )).filter(y => !!y);
43
+ // and draw them...
44
+ }
45
+ ```
46
+
47
+ Instead of using the full `client.product` you could have resolved it via the `.productSearch` to get a smaller data-footprint. However, since you are hopefully just about to navigate to one of these pages, it might be better for overall cache performance to use the full data model.
48
+
49
+
50
+
51
+
52
+ ## Collections
53
+
54
+ Some systems offer named access to a potentially rules based selection of products. An example is the HCL Commerce espot system, where your frontpage migth have a spot called "Newest arrivals", and behind the scenes some rules engine decides what to show there.
55
+
56
+ It can also be simply the name of a CMS entity, or other system specific functionality.
57
+
58
+ You can call that as well here:
59
+
60
+ ```ts
61
+ const recommendations = client.productRecommendations.getCollection({
62
+ collectionName: 'newest-arrivals',
63
+ numberOfRecommendations: 8,
64
+ labels: [
65
+ (
66
+ isLoggedIn: 'Registered' : 'Guest',
67
+ registeredThisSession? 'FirstSession': 'ReturningCustomer',
68
+ customerSegments.map(x => x.name),
69
+ new Date().getHour() > 12? "Afternoon": "Morning",
70
+ getTemperatureAround(context.clientIp) < 20: 'Cold': 'Warm'
71
+ )
72
+ ]
73
+ });
74
+
75
+ if (recommendations.success) {
76
+ // we can now resolve them
77
+ const allProducts = Promise.all(
78
+ recommendations.value.products.map(x => client.product.getByID(x)).map(x => x.successs? x.value : null )).filter(y => !!y);
79
+ // and draw them...
80
+ }
81
+ ```
82
+
83
+ ### Configuration
84
+ Since not every system can provide every recommendation algorithm, Reactionary supports enabling this capability on multiple providers.
85
+
86
+ So if you have both algolia and medusa, you can set `productRecommendations: true` on both, and the system will then ask each system in turn until the required number of results have been gathered.
87
+
88
+
89
+
90
+ ## Design decisions
91
+ Since not all systems can deliver full product data back, we are initially only returning the IDs of the products, so you have to use the `product` provider to resolve it into something displayable.
92
+
93
+ Later, we might add a discriminator, allowing the same bare-bones/tile-ready data to be returned from this, as is returned from the product search.
94
+
95
+ It was also decided to have the source and target unit be a product (Forest Ranger T-Shirt), and not the individual sku (Forest Ranger T-Shirt, Yellow, Size-32).
96
+
97
+ This is from emperical studies showign that you will get more results from the toplevel product, than from a rarely used SKU.
3
98
 
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@reactionary/examples-node",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "main": "index.js",
5
5
  "types": "src/index.d.ts",
6
6
  "dependencies": {
7
- "@reactionary/core": "0.3.2",
8
- "@reactionary/provider-commercetools": "0.3.2",
9
- "@reactionary/provider-algolia": "0.3.2",
10
- "@reactionary/provider-medusa": "0.3.2",
11
- "@reactionary/provider-meilisearch": "0.3.2"
7
+ "@reactionary/core": "0.3.3",
8
+ "@reactionary/provider-commercetools": "0.3.3",
9
+ "@reactionary/provider-algolia": "0.3.3",
10
+ "@reactionary/provider-medusa": "0.3.3",
11
+ "@reactionary/provider-meilisearch": "0.3.3"
12
12
  },
13
13
  "type": "module"
14
14
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reactionary/source",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "dependencies": {
@@ -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,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
+ }
@@ -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';
@@ -0,0 +1,89 @@
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
+ } from '@reactionary/core';
11
+ import { MeiliSearch, type Hits, type RecordAny, type SearchParams, type SearchResponse } from 'meilisearch';
12
+ import type { MeilisearchConfiguration } from '../schema/configuration.schema.js';
13
+
14
+ interface MeilisearchRecommendHit {
15
+ id: string;
16
+ }
17
+
18
+ /**
19
+ * MeilisearchProductRecommendationsProvider
20
+ *
21
+ * Provides product recommendations using Meilisearch's hybrid search and filtering capabilities.
22
+ * Supports frequentlyBoughtTogether, similar, related, and trendingInCategory algorithms.
23
+ *
24
+ * Note: This implementation uses semantic search (if AI embedding is enabled) and facet-based filtering.
25
+ * For production use, consider implementing more sophisticated recommendation logic or integrating
26
+ * with a dedicated recommendation engine.
27
+ */
28
+ export class MeilisearchProductRecommendationsProvider extends ProductRecommendationsProvider {
29
+ protected config: MeilisearchConfiguration;
30
+
31
+ constructor(config: MeilisearchConfiguration, cache: Cache, context: RequestContext) {
32
+ super(cache, context);
33
+ this.config = config;
34
+ }
35
+
36
+ /**
37
+ * Get similar product recommendations
38
+ * Uses semantic search to find visually or data-wise similar products
39
+ */
40
+ protected override async getSimilarProductsRecommendations(
41
+ query: ProductRecommendationAlgorithmSimilarProductsQuery
42
+ ): Promise<ProductRecommendation[]> {
43
+ const client = new MeiliSearch({
44
+ host: this.config.apiUrl,
45
+ apiKey: this.config.apiKey,
46
+ });
47
+
48
+ const index = client.index(this.config.indexName);
49
+
50
+ if (!this.config.useAIEmbedding) {
51
+ console.warn('AI embedding is not enabled in configuration. Similar product recommendations will be based on keyword matching, which may not provide optimal results.');
52
+ return [];
53
+ }
54
+
55
+ try {
56
+ const searchOptions: SearchParams = {
57
+ limit: query.numberOfRecommendations,
58
+ };
59
+
60
+ const response = await index.searchSimilarDocuments<MeilisearchRecommendHit>({
61
+ id: query.sourceProduct.key,
62
+ limit: query.numberOfRecommendations,
63
+ embedder: this.config.useAIEmbedding,
64
+ });
65
+
66
+
67
+ return this.parseRecommendations(response, 'similar');
68
+ } catch (error) {
69
+ console.error('Error fetching similar product recommendations:', error);
70
+ return [];
71
+ }
72
+ }
73
+
74
+
75
+ /**
76
+ * Maps Meilisearch search results to ProductRecommendation format
77
+ */
78
+ protected parseRecommendations(recommendation: SearchResponse<MeilisearchRecommendHit>, algorithm: string): ProductRecommendation[] {
79
+ return recommendation.hits.map((hit) => ({
80
+ recommendationIdentifier: {
81
+ key: hit.id,
82
+ algorithm,
83
+ },
84
+ product: {
85
+ key: hit.id,
86
+ },
87
+ }));
88
+ }
89
+ }
@@ -3,6 +3,7 @@ import type { z } from 'zod';
3
3
 
4
4
  export const MeilisearchCapabilitiesSchema = CapabilitiesSchema.pick({
5
5
  productSearch: true,
6
+ productRecommendations: true,
6
7
  orderSearch: true,
7
8
  analytics: true
8
9
  }).partial();