@reactionary/source 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/client/client-builder.ts +8 -0
- package/core/src/client/client.ts +4 -0
- package/core/src/initialization.ts +4 -1
- package/core/src/providers/identity.provider.ts +5 -0
- package/core/src/providers/index.ts +2 -0
- package/core/src/providers/product-associations.provider.ts +49 -0
- package/core/src/providers/product-recommendations.provider.ts +150 -0
- package/core/src/schemas/capabilities.schema.ts +2 -0
- package/core/src/schemas/models/identifiers.model.ts +8 -0
- package/core/src/schemas/models/index.ts +1 -0
- package/core/src/schemas/models/product-recommendations.model.ts +10 -0
- package/core/src/schemas/queries/index.ts +2 -0
- package/core/src/schemas/queries/product-associations.query.ts +23 -0
- package/core/src/schemas/queries/product-recommendations.query.ts +72 -0
- package/core/src/schemas/session.schema.ts +2 -1
- package/documentation/docs/7-marketing.md +95 -0
- package/documentation/docs/8-tracking.md +1 -1
- package/examples/node/package.json +6 -6
- package/examples/node/src/basic/client-creation.spec.ts +2 -2
- package/examples/node/src/capabilities/product-recommendations.spec.ts +96 -0
- package/examples/node/src/utils.ts +3 -0
- package/package.json +1 -1
- package/providers/algolia/README.md +12 -4
- package/providers/algolia/src/core/initialize.ts +8 -2
- package/providers/algolia/src/index.ts +1 -0
- package/providers/algolia/src/providers/analytics.provider.ts +9 -7
- package/providers/algolia/src/providers/index.ts +2 -1
- package/providers/algolia/src/providers/product-recommendations.provider.ts +234 -0
- package/providers/algolia/src/providers/product-search.provider.ts +5 -4
- package/providers/algolia/src/schema/capabilities.schema.ts +2 -1
- package/providers/algolia/src/schema/product-recommendation.schema.ts +9 -0
- package/providers/algolia/src/test/analytics.spec.ts +138 -0
- package/providers/commercetools/src/providers/identity.provider.ts +8 -1
- package/providers/commercetools/src/test/caching.spec.ts +3 -3
- package/providers/commercetools/src/test/identity.spec.ts +2 -2
- package/providers/google-analytics/package.json +0 -6
- package/providers/medusa/src/core/initialize.ts +6 -0
- package/providers/medusa/src/index.ts +1 -0
- package/providers/medusa/src/providers/identity.provider.ts +34 -10
- package/providers/medusa/src/providers/product-recommendations.provider.ts +117 -0
- package/providers/medusa/src/schema/capabilities.schema.ts +1 -0
- package/providers/meilisearch/src/core/initialize.ts +6 -0
- package/providers/meilisearch/src/index.ts +1 -0
- package/providers/meilisearch/src/providers/index.ts +1 -0
- package/providers/meilisearch/src/providers/product-recommendations.provider.ts +89 -0
- package/providers/meilisearch/src/schema/capabilities.schema.ts +1 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, assert } from 'vitest';
|
|
2
|
+
import { AlgoliaAnalyticsProvider } from '../providers/analytics.provider.js';
|
|
3
|
+
import { createInitialRequestContext, NoOpCache } from '@reactionary/core';
|
|
4
|
+
import type { AlgoliaConfiguration } from '../schema/configuration.schema.js';
|
|
5
|
+
import { AlgoliaProductSearchProvider } from '../providers/product-search.provider.js';
|
|
6
|
+
|
|
7
|
+
describe('Analytics event tracking', async () => {
|
|
8
|
+
const config = {
|
|
9
|
+
apiKey: process.env['ALGOLIA_API_KEY'] || '',
|
|
10
|
+
appId: process.env['ALGOLIA_APP_ID'] || '',
|
|
11
|
+
indexName: process.env['ALGOLIA_INDEX'] || '',
|
|
12
|
+
} satisfies AlgoliaConfiguration;
|
|
13
|
+
const cache = new NoOpCache();
|
|
14
|
+
const context = createInitialRequestContext();
|
|
15
|
+
|
|
16
|
+
const search = new AlgoliaProductSearchProvider(cache, context, config);
|
|
17
|
+
const analytics = new AlgoliaAnalyticsProvider(cache, context, config);
|
|
18
|
+
const searchResult = await search.queryByTerm({
|
|
19
|
+
search: {
|
|
20
|
+
facets: [],
|
|
21
|
+
filters: [],
|
|
22
|
+
paginationOptions: {
|
|
23
|
+
pageNumber: 1,
|
|
24
|
+
pageSize: 10,
|
|
25
|
+
},
|
|
26
|
+
term: 'q',
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!searchResult.success) {
|
|
31
|
+
assert.fail();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
it('can track summary clicks', async () => {
|
|
35
|
+
await analytics.track({
|
|
36
|
+
event: 'product-summary-click',
|
|
37
|
+
product: searchResult.value.items[0].identifier,
|
|
38
|
+
position: 1,
|
|
39
|
+
source: {
|
|
40
|
+
type: 'search',
|
|
41
|
+
identifier: searchResult.value.identifier,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('can track summary views', async () => {
|
|
47
|
+
await analytics.track({
|
|
48
|
+
event: 'product-summary-view',
|
|
49
|
+
products: searchResult.value.items.map((x) => x.identifier),
|
|
50
|
+
source: {
|
|
51
|
+
type: 'search',
|
|
52
|
+
identifier: searchResult.value.identifier,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('can track add to cart', async () => {
|
|
58
|
+
await analytics.track({
|
|
59
|
+
event: 'product-cart-add',
|
|
60
|
+
product: searchResult.value.items[0].identifier,
|
|
61
|
+
source: {
|
|
62
|
+
type: 'search',
|
|
63
|
+
identifier: searchResult.value.identifier,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('can track purchase', async () => {
|
|
69
|
+
await analytics.track({
|
|
70
|
+
event: 'purchase',
|
|
71
|
+
order: {
|
|
72
|
+
identifier: {
|
|
73
|
+
key: crypto.randomUUID(),
|
|
74
|
+
},
|
|
75
|
+
inventoryStatus: 'Allocated',
|
|
76
|
+
items: [
|
|
77
|
+
{
|
|
78
|
+
identifier: {
|
|
79
|
+
key: crypto.randomUUID(),
|
|
80
|
+
},
|
|
81
|
+
inventoryStatus: 'Allocated',
|
|
82
|
+
price: {
|
|
83
|
+
unitPrice: {
|
|
84
|
+
currency: 'USD',
|
|
85
|
+
value: 50,
|
|
86
|
+
},
|
|
87
|
+
totalDiscount: {
|
|
88
|
+
currency: 'USD',
|
|
89
|
+
value: 0,
|
|
90
|
+
},
|
|
91
|
+
totalPrice: {
|
|
92
|
+
currency: 'USD',
|
|
93
|
+
value: 50,
|
|
94
|
+
},
|
|
95
|
+
unitDiscount: {
|
|
96
|
+
currency: 'USD',
|
|
97
|
+
value: 0,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
quantity: 1,
|
|
101
|
+
variant: searchResult.value.items[0].variants[0].variant,
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
orderStatus: 'Shipped',
|
|
105
|
+
paymentInstructions: [],
|
|
106
|
+
price: {
|
|
107
|
+
grandTotal: {
|
|
108
|
+
currency: 'USD',
|
|
109
|
+
value: 50,
|
|
110
|
+
},
|
|
111
|
+
totalDiscount: {
|
|
112
|
+
currency: 'USD',
|
|
113
|
+
value: 0,
|
|
114
|
+
},
|
|
115
|
+
totalProductPrice: {
|
|
116
|
+
currency: 'USD',
|
|
117
|
+
value: 50,
|
|
118
|
+
},
|
|
119
|
+
totalShipping: {
|
|
120
|
+
currency: 'USD',
|
|
121
|
+
value: 0,
|
|
122
|
+
},
|
|
123
|
+
totalSurcharge: {
|
|
124
|
+
currency: 'USD',
|
|
125
|
+
value: 0,
|
|
126
|
+
},
|
|
127
|
+
totalTax: {
|
|
128
|
+
currency: 'USD',
|
|
129
|
+
value: 0,
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
userId: {
|
|
133
|
+
userId: crypto.randomUUID()
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
success,
|
|
16
16
|
} from '@reactionary/core';
|
|
17
17
|
import type { CommercetoolsConfiguration } from '../schema/configuration.schema.js';
|
|
18
|
-
import type z from 'zod';
|
|
19
18
|
import type { CommercetoolsAPI } from '../core/client.js';
|
|
20
19
|
|
|
21
20
|
export class CommercetoolsIdentityProvider extends IdentityProvider {
|
|
@@ -41,6 +40,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
|
|
|
41
40
|
public override async getSelf(payload: IdentityQuerySelf): Promise<Result<Identity>> {
|
|
42
41
|
const identity = await this.commercetools.introspect();
|
|
43
42
|
|
|
43
|
+
this.updateIdentityContext(identity);
|
|
44
|
+
|
|
44
45
|
return success(identity);
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -54,6 +55,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
|
|
|
54
55
|
payload.password
|
|
55
56
|
);
|
|
56
57
|
|
|
58
|
+
this.updateIdentityContext(identity);
|
|
59
|
+
|
|
57
60
|
return success(identity);
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -63,6 +66,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
|
|
|
63
66
|
public override async logout(payload: Record<string, never>): Promise<Result<Identity>> {
|
|
64
67
|
const identity = await this.commercetools.logout();
|
|
65
68
|
|
|
69
|
+
this.updateIdentityContext(identity);
|
|
70
|
+
|
|
66
71
|
return success(identity);
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -78,6 +83,8 @@ export class CommercetoolsIdentityProvider extends IdentityProvider {
|
|
|
78
83
|
payload.password
|
|
79
84
|
);
|
|
80
85
|
|
|
86
|
+
this.updateIdentityContext(identity);
|
|
87
|
+
|
|
81
88
|
return success(identity);
|
|
82
89
|
}
|
|
83
90
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type ProductSearchQueryByTerm,
|
|
9
9
|
} from '@reactionary/core';
|
|
10
10
|
import { CommercetoolsProductProvider } from '../providers/product.provider.js';
|
|
11
|
-
import {
|
|
11
|
+
import { CommercetoolsAPI } from '../core/client.js';
|
|
12
12
|
import { CommercetoolsSearchProvider } from '../providers/product-search.provider.js';
|
|
13
13
|
|
|
14
14
|
describe('Caching', () => {
|
|
@@ -16,7 +16,7 @@ describe('Caching', () => {
|
|
|
16
16
|
const config = getCommercetoolsTestConfiguration();
|
|
17
17
|
const context = createInitialRequestContext();
|
|
18
18
|
const cache = new MemoryCache();
|
|
19
|
-
const client = new
|
|
19
|
+
const client = new CommercetoolsAPI(config, context);
|
|
20
20
|
const provider = new CommercetoolsProductProvider(config, cache, context, client);
|
|
21
21
|
|
|
22
22
|
const identifier = {
|
|
@@ -48,7 +48,7 @@ describe('Caching', () => {
|
|
|
48
48
|
const config = getCommercetoolsTestConfiguration();
|
|
49
49
|
const context = createInitialRequestContext();
|
|
50
50
|
const cache = new MemoryCache();
|
|
51
|
-
const client = new
|
|
51
|
+
const client = new CommercetoolsAPI(config, context);
|
|
52
52
|
const provider = new CommercetoolsSearchProvider(config, cache, context, client);
|
|
53
53
|
|
|
54
54
|
const query = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import { CommercetoolsAPI } from '../core/client.js';
|
|
4
4
|
import { getCommercetoolsTestConfiguration } from './test-utils.js';
|
|
5
5
|
import {
|
|
6
6
|
createInitialRequestContext,
|
|
@@ -12,7 +12,7 @@ import type { CommercetoolsConfiguration } from '../schema/configuration.schema.
|
|
|
12
12
|
function setup() {
|
|
13
13
|
const config = getCommercetoolsTestConfiguration();
|
|
14
14
|
const context = createInitialRequestContext();
|
|
15
|
-
const root = new
|
|
15
|
+
const root = new CommercetoolsAPI(config, context);
|
|
16
16
|
|
|
17
17
|
return {
|
|
18
18
|
config,
|
|
@@ -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';
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
success,
|
|
20
20
|
} from '@reactionary/core';
|
|
21
21
|
import type { MedusaConfiguration } from '../schema/configuration.schema.js';
|
|
22
|
-
import type z from 'zod';
|
|
23
22
|
import type { MedusaAPI } from '../core/client.js';
|
|
24
23
|
import createDebug from 'debug';
|
|
25
24
|
|
|
@@ -60,7 +59,10 @@ export class MedusaIdentityProvider extends IdentityProvider {
|
|
|
60
59
|
|
|
61
60
|
if (!token) {
|
|
62
61
|
debug('No active session token found, returning anonymous identity');
|
|
63
|
-
|
|
62
|
+
const identity = this.createAnonymousIdentity();
|
|
63
|
+
this.updateIdentityContext(identity);
|
|
64
|
+
|
|
65
|
+
return success(identity);
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
// Try to fetch customer details to verify authentication
|
|
@@ -68,18 +70,30 @@ export class MedusaIdentityProvider extends IdentityProvider {
|
|
|
68
70
|
|
|
69
71
|
if (customerResponse.customer) {
|
|
70
72
|
debug('Customer authenticated:', customerResponse.customer.email);
|
|
71
|
-
|
|
73
|
+
|
|
74
|
+
const identity = {
|
|
72
75
|
id: {
|
|
73
76
|
userId: customerResponse.customer.id,
|
|
74
77
|
},
|
|
75
78
|
type: 'Registered',
|
|
76
|
-
} satisfies RegisteredIdentity
|
|
79
|
+
} satisfies RegisteredIdentity;
|
|
80
|
+
|
|
81
|
+
this.updateIdentityContext(identity);
|
|
82
|
+
|
|
83
|
+
return success(identity);
|
|
77
84
|
}
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
const identity = this.createAnonymousIdentity();
|
|
87
|
+
this.updateIdentityContext(identity);
|
|
88
|
+
|
|
89
|
+
return success(identity);
|
|
80
90
|
} catch (error) {
|
|
81
91
|
debug('getSelf failed, returning anonymous identity:', error);
|
|
82
|
-
|
|
92
|
+
|
|
93
|
+
const identity = this.createAnonymousIdentity();
|
|
94
|
+
this.updateIdentityContext(identity);
|
|
95
|
+
|
|
96
|
+
return success(identity);
|
|
83
97
|
}
|
|
84
98
|
}
|
|
85
99
|
|
|
@@ -87,13 +101,17 @@ export class MedusaIdentityProvider extends IdentityProvider {
|
|
|
87
101
|
inputSchema: IdentityMutationLoginSchema,
|
|
88
102
|
outputSchema: IdentitySchema,
|
|
89
103
|
})
|
|
90
|
-
public override async login(
|
|
104
|
+
public override async login(
|
|
105
|
+
payload: IdentityMutationLogin
|
|
106
|
+
): Promise<Result<Identity>> {
|
|
91
107
|
debug('Attempting login for user:', payload.username);
|
|
92
|
-
const identity = await this.medusaApi.login(
|
|
108
|
+
const identity = (await this.medusaApi.login(
|
|
93
109
|
payload.username,
|
|
94
110
|
payload.password,
|
|
95
111
|
this.context
|
|
96
|
-
) satisfies Identity;
|
|
112
|
+
)) satisfies Identity;
|
|
113
|
+
|
|
114
|
+
this.updateIdentityContext(identity);
|
|
97
115
|
|
|
98
116
|
return success(identity);
|
|
99
117
|
}
|
|
@@ -102,10 +120,14 @@ export class MedusaIdentityProvider extends IdentityProvider {
|
|
|
102
120
|
inputSchema: IdentityMutationLogoutSchema,
|
|
103
121
|
outputSchema: IdentitySchema,
|
|
104
122
|
})
|
|
105
|
-
public override async logout(
|
|
123
|
+
public override async logout(
|
|
124
|
+
_payload: IdentityMutationLogout
|
|
125
|
+
): Promise<Result<Identity>> {
|
|
106
126
|
debug('Logging out user');
|
|
107
127
|
const identity = await this.medusaApi.logout(this.context);
|
|
108
128
|
|
|
129
|
+
this.updateIdentityContext(identity);
|
|
130
|
+
|
|
109
131
|
return success(identity);
|
|
110
132
|
}
|
|
111
133
|
|
|
@@ -132,6 +154,8 @@ export class MedusaIdentityProvider extends IdentityProvider {
|
|
|
132
154
|
this.context
|
|
133
155
|
);
|
|
134
156
|
|
|
157
|
+
this.updateIdentityContext(identity);
|
|
158
|
+
|
|
135
159
|
return success(identity);
|
|
136
160
|
}
|
|
137
161
|
}
|
|
@@ -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';
|
|
@@ -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
|
+
}
|