@seekora-ai/search-sdk 0.2.7 → 0.2.12

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/dist/client.d.ts CHANGED
@@ -66,6 +66,46 @@ export interface SeekoraClientConfig {
66
66
  time_range?: '7d' | '30d' | '90d';
67
67
  [key: string]: unknown;
68
68
  };
69
+ /**
70
+ * Automatically track search result impressions
71
+ * When enabled, impression events are sent for all items in search results
72
+ * @default true
73
+ */
74
+ autoTrackImpressions?: boolean;
75
+ /**
76
+ * Delay before tracking impressions (ms)
77
+ * Useful to ensure items are actually visible to the user
78
+ * @default 0
79
+ */
80
+ impressionTrackingDelay?: number;
81
+ /**
82
+ * Enable event deduplication (industry standard: Segment, Amplitude pattern)
83
+ * When enabled, generates insert_id for backend deduplication
84
+ *
85
+ * Philosophy: Track everything by default, provide tools for deduplication
86
+ * - false (default): Track all events, no client-side filtering
87
+ * - true: Generate insert_id for optional backend deduplication
88
+ *
89
+ * @default false
90
+ */
91
+ enableDeduplication?: boolean;
92
+ /**
93
+ * Deduplication time window in milliseconds
94
+ * Events with same user+event_type+item within this window get same insert_id
95
+ *
96
+ * Industry standards:
97
+ * - Segment: 10 minutes (600000ms)
98
+ * - Mixpanel: 5 days (but we use shorter for engagement events)
99
+ * - Amplitude: 7 days (for all events)
100
+ *
101
+ * Recommended by event type:
102
+ * - Engagement (click, view, share): 5 minutes (300000ms)
103
+ * - Conversion (add_to_cart, wishlist): 5 minutes (300000ms)
104
+ * - Purchase: 30 days (2592000000ms) - handled separately
105
+ *
106
+ * @default 300000 (5 minutes)
107
+ */
108
+ deduplicationWindow?: number;
69
109
  }
70
110
  /**
71
111
  * Search context containing identifiers for linking events to searches
@@ -78,6 +118,8 @@ export interface SearchContext {
78
118
  correlationId: string;
79
119
  /** Search ID from backend (if present in response) */
80
120
  searchId?: string;
121
+ /** Journey ID for session-level tracking */
122
+ journey_id?: string;
81
123
  /** User ID if authenticated */
82
124
  userId?: string;
83
125
  /** Anonymous user ID */
@@ -101,6 +143,10 @@ export interface ExtendedEventPayload extends DataTypesEventPayload {
101
143
  correlation_id?: string;
102
144
  /** Search ID at top level (also in metadata for backward compat) */
103
145
  search_id?: string;
146
+ /** Insert ID for event deduplication (industry standard: Segment/Amplitude pattern) */
147
+ insert_id?: string;
148
+ /** Conversion type for conversion events (add_to_cart, wishlist, purchase, etc.) */
149
+ conversion_type?: string;
104
150
  }
105
151
  export interface SearchOptions {
106
152
  q: string;
@@ -282,6 +328,8 @@ export declare class SeekoraClient {
282
328
  private enableEventQueue;
283
329
  private eventQueue;
284
330
  private clientConfig;
331
+ private enableDeduplication;
332
+ private deduplicationWindow;
285
333
  constructor(config?: SeekoraClientConfig);
286
334
  /**
287
335
  * Search for documents
@@ -297,11 +345,17 @@ export declare class SeekoraClient {
297
345
  /**
298
346
  * Get query suggestions
299
347
  *
300
- * Uses POST when filtered_tabs or include_dropdown_recommendations is set (GET does not send include_dropdown_recommendations).
301
- * With returnFullResponse: true returns full shape including extensions (trending_searches, top_searches, related_searches, popular_brands, filtered_tabs).
348
+ * **NEW:** Full GET/POST parity! Automatically chooses the best method:
349
+ * - GET: Default for simple requests, including filtered_tabs with ≤5 tabs (faster, cacheable)
350
+ * - POST: Used for complex filtered_tabs (>5 tabs) or when explicitly requested via options.method = 'POST'
351
+ *
352
+ * With returnFullResponse: true returns full shape including extensions (dropdown_recommendations with trending products, filtered_tabs).
302
353
  *
303
354
  * @param query - Partial query to get suggestions for
304
355
  * @param options - Optional parameters for suggestions
356
+ * @param options.filtered_tabs - Tab configurations (now supported in GET!)
357
+ * @param options.include_dropdown_recommendations - Include rich dropdown data
358
+ * @param options.method - Explicitly set 'POST' to force POST method
305
359
  * @returns Suggestion hits array, or QuerySuggestionsFullResponse when returnFullResponse is true
306
360
  */
307
361
  getSuggestions(query: string, options?: {
@@ -473,6 +527,11 @@ export declare class SeekoraClient {
473
527
  * Send a batch of events directly to the backend (bypasses queue)
474
528
  */
475
529
  private sendEventsBatchDirect;
530
+ /**
531
+ * Automatically track search result impressions
532
+ * Creates a batch of view events for all items in search results
533
+ */
534
+ private autoTrackSearchImpressions;
476
535
  /**
477
536
  * Get browser context (sync if cached, otherwise returns null)
478
537
  * Use collectBrowserContext() for async collection
@@ -482,6 +541,19 @@ export declare class SeekoraClient {
482
541
  * Collect browser context asynchronously
483
542
  */
484
543
  collectBrowserContext(): Promise<BrowserContext>;
544
+ /**
545
+ * Generate insert_id for event deduplication (industry standard)
546
+ *
547
+ * Format: {user_key}_{event_type}_{item_id}_{timestamp_window}
548
+ * Similar to Segment, Amplitude, Mixpanel deduplication keys
549
+ *
550
+ * @param eventType - Event name (search, click, conversion, etc.)
551
+ * @param itemId - Item ID (optional, for item-specific events)
552
+ * @param userId - User ID or anonymous ID
553
+ * @param window - Deduplication window in milliseconds
554
+ * @returns insert_id string for backend deduplication
555
+ */
556
+ private generateInsertId;
485
557
  /**
486
558
  * Build event payload with identifiers and browser context
487
559
  * Ensures user_id or anon_id is present, and sets correlation_id/search_id at top level
@@ -586,6 +658,53 @@ export declare class SeekoraClient {
586
658
  * @param context - Optional search context for linking events to searches
587
659
  */
588
660
  trackCustom(eventName: string, payload?: Partial<DataTypesEventPayload>, context?: SearchContext): Promise<void>;
661
+ /**
662
+ * Track add to cart conversion event
663
+ *
664
+ * @param params - Add to cart parameters
665
+ * @param params.itemId - Item ID that was added to cart
666
+ * @param params.quantity - Quantity added (default: 1)
667
+ * @param params.value - Item value/price
668
+ * @param params.currency - Currency code (default: 'USD')
669
+ * @param params.position - Position in search results (if from search)
670
+ * @param params.searchContext - Search context for attribution
671
+ */
672
+ trackAddToCart(params: {
673
+ itemId: string;
674
+ quantity?: number;
675
+ value?: number;
676
+ currency?: string;
677
+ position?: number;
678
+ searchContext?: SearchContext;
679
+ }): Promise<void>;
680
+ /**
681
+ * Track add to wishlist conversion event
682
+ *
683
+ * @param params - Add to wishlist parameters
684
+ * @param params.itemId - Item ID that was added to wishlist
685
+ * @param params.position - Position in search results (if from search)
686
+ * @param params.searchContext - Search context for attribution
687
+ */
688
+ trackAddToWishlist(params: {
689
+ itemId: string;
690
+ position?: number;
691
+ searchContext?: SearchContext;
692
+ }): Promise<void>;
693
+ /**
694
+ * Track item share event
695
+ *
696
+ * @param params - Share parameters
697
+ * @param params.itemId - Item ID that was shared
698
+ * @param params.shareMethod - Share method (e.g., 'facebook', 'twitter', 'whatsapp', 'copy_link')
699
+ * @param params.position - Position in search results (if from search)
700
+ * @param params.searchContext - Search context for attribution
701
+ */
702
+ trackShare(params: {
703
+ itemId: string;
704
+ shareMethod?: string;
705
+ position?: number;
706
+ searchContext?: SearchContext;
707
+ }): Promise<void>;
589
708
  /**
590
709
  * Validate an event payload before sending
591
710
  * Useful for debugging and ensuring events are properly formatted
package/dist/client.js CHANGED
@@ -56,6 +56,9 @@ class SeekoraClient {
56
56
  this.eventQueue.setLogger(this.logger);
57
57
  this.eventQueue.setSender(this.createQueueSender());
58
58
  }
59
+ // Initialize deduplication settings (industry standard: Segment/Amplitude pattern)
60
+ this.enableDeduplication = config.enableDeduplication || false;
61
+ this.deduplicationWindow = config.deduplicationWindow || 300000; // Default: 5 minutes
59
62
  // Log identifier initialization
60
63
  this.logger.verbose('Client identifiers initialized', {
61
64
  hasUserId: !!this.userId,
@@ -63,7 +66,9 @@ class SeekoraClient {
63
66
  sessionId: this.sessionId.substring(0, 8) + '...',
64
67
  autoTrackSearch: this.autoTrackSearch,
65
68
  enableContextCollection: this.enableContextCollection,
66
- enableEventQueue: this.enableEventQueue
69
+ enableEventQueue: this.enableEventQueue,
70
+ enableDeduplication: this.enableDeduplication,
71
+ deduplicationWindow: this.deduplicationWindow
67
72
  });
68
73
  this.logger.verbose('Initializing SeekoraClient', {
69
74
  storeId: this.storeId,
@@ -275,6 +280,22 @@ class SeekoraClient {
275
280
  this.logger.warn('Failed to auto-track search event', { error: err.message });
276
281
  });
277
282
  }
283
+ // Automatically track search result impressions if enabled
284
+ if (this.clientConfig.autoTrackImpressions !== false && results.results && results.results.length > 0) {
285
+ const delay = this.clientConfig.impressionTrackingDelay || 0;
286
+ const trackImpressions = () => {
287
+ this.autoTrackSearchImpressions(results, context).catch((err) => {
288
+ // Log but don't fail the search if tracking fails
289
+ this.logger.warn('Failed to auto-track impressions', { error: err.message });
290
+ });
291
+ };
292
+ if (delay > 0) {
293
+ setTimeout(trackImpressions, delay);
294
+ }
295
+ else {
296
+ trackImpressions();
297
+ }
298
+ }
278
299
  return results;
279
300
  }
280
301
  catch (error) {
@@ -304,11 +325,17 @@ class SeekoraClient {
304
325
  /**
305
326
  * Get query suggestions
306
327
  *
307
- * Uses POST when filtered_tabs or include_dropdown_recommendations is set (GET does not send include_dropdown_recommendations).
308
- * With returnFullResponse: true returns full shape including extensions (trending_searches, top_searches, related_searches, popular_brands, filtered_tabs).
328
+ * **NEW:** Full GET/POST parity! Automatically chooses the best method:
329
+ * - GET: Default for simple requests, including filtered_tabs with ≤5 tabs (faster, cacheable)
330
+ * - POST: Used for complex filtered_tabs (>5 tabs) or when explicitly requested via options.method = 'POST'
331
+ *
332
+ * With returnFullResponse: true returns full shape including extensions (dropdown_recommendations with trending products, filtered_tabs).
309
333
  *
310
334
  * @param query - Partial query to get suggestions for
311
335
  * @param options - Optional parameters for suggestions
336
+ * @param options.filtered_tabs - Tab configurations (now supported in GET!)
337
+ * @param options.include_dropdown_recommendations - Include rich dropdown data
338
+ * @param options.method - Explicitly set 'POST' to force POST method
312
339
  * @returns Suggestion hits array, or QuerySuggestionsFullResponse when returnFullResponse is true
313
340
  */
314
341
  async getSuggestions(query, options) {
@@ -324,10 +351,14 @@ class SeekoraClient {
324
351
  headers['x-anon-id'] = this.anonId;
325
352
  if (this.sessionId)
326
353
  headers['x-session-id'] = this.sessionId;
327
- const usePost = (options?.filtered_tabs && options.filtered_tabs.length > 0) ||
328
- options?.include_dropdown_recommendations === true ||
329
- options?.include_dropdown_product_list === false ||
330
- options?.include_filtered_tabs === false;
354
+ // Use POST for:
355
+ // - Complex filtered_tabs (5+ tabs or very long JSON would exceed URL limits)
356
+ // - Explicit preference for POST (options.method === 'POST')
357
+ // Use GET for everything else (including simple filtered_tabs)
358
+ const hasComplexFilteredTabs = options?.filtered_tabs &&
359
+ options.filtered_tabs.length > 5;
360
+ const usePost = hasComplexFilteredTabs ||
361
+ options?.method === 'POST'; // Allow explicit POST preference
331
362
  const defaults = this.clientConfig?.suggestionsDefaults;
332
363
  const buildRequestBody = () => {
333
364
  const body = {
@@ -385,15 +416,27 @@ class SeekoraClient {
385
416
  });
386
417
  return suggestions;
387
418
  }
388
- // GET for simple requests (no filtered_tabs, no include_dropdown_recommendations)
419
+ // GET for simple requests and simple filtered_tabs (URL-encoded JSON)
389
420
  this.logger.verbose('Using GET endpoint', {
390
421
  endpoint: '/api/v1/suggestions/queries',
391
422
  query,
423
+ hasFilteredTabs: !!(options?.filtered_tabs && options.filtered_tabs.length > 0),
392
424
  });
393
425
  const analyticsTags = Array.isArray(options?.analytics_tags)
394
426
  ? options.analytics_tags.join(',')
395
427
  : options?.analytics_tags;
396
- const response = await this.suggestionsApi.v1SuggestionsQueriesGet(this.storeId, this.readSecret, this.userId, this.anonId, this.sessionId, query, options?.hitsPerPage || 5, options?.page, analyticsTags, options?.tags_match_mode, options?.include_categories, options?.include_facets, options?.max_categories, options?.max_facets, options?.min_popularity, options?.time_range, options?.disable_typo_tolerance, { headers });
428
+ // Encode filtered_tabs as JSON string for URL parameter
429
+ const filteredTabsParam = options?.filtered_tabs
430
+ ? JSON.stringify(options.filtered_tabs)
431
+ : undefined;
432
+ const response = await this.suggestionsApi.v1SuggestionsQueriesGet(this.storeId, this.readSecret, this.userId, this.anonId, this.sessionId, query, // query parameter
433
+ undefined, // q parameter (alias for query)
434
+ options?.hitsPerPage || 5, options?.page, analyticsTags, options?.tags_match_mode, options?.include_categories, options?.include_facets, options?.include_dropdown_recommendations, options?.include_dropdown_product_list, options?.include_filtered_tabs, undefined, // include_empty_query_recommendations
435
+ options?.max_categories, options?.max_facets, options?.min_popularity, options?.time_range, options?.disable_typo_tolerance, filteredTabsParam, // filtered_tabs as JSON string
436
+ undefined, // userId (using header instead)
437
+ undefined, // anonId (using header instead)
438
+ undefined, // sessionId (using header instead)
439
+ { headers });
397
440
  const responseData = response.data?.data || response.data;
398
441
  const suggestions = responseData?.results?.[0]?.hits || responseData?.hits || [];
399
442
  if (options?.returnFullResponse) {
@@ -1026,6 +1069,52 @@ class SeekoraClient {
1026
1069
  throw new Error(`Failed to send batch events: ${response.status}`);
1027
1070
  }
1028
1071
  }
1072
+ /**
1073
+ * Automatically track search result impressions
1074
+ * Creates a batch of view events for all items in search results
1075
+ */
1076
+ async autoTrackSearchImpressions(searchResponse, context) {
1077
+ const items = searchResponse.results || [];
1078
+ if (items.length === 0) {
1079
+ return;
1080
+ }
1081
+ this.logger.verbose('Auto-tracking search impressions', {
1082
+ itemCount: items.length,
1083
+ query: searchResponse.query || searchResponse.data?.data?.query,
1084
+ searchId: context.searchId
1085
+ });
1086
+ // Create batch impression events
1087
+ const impressionEvents = items.map((item, index) => ({
1088
+ event_name: 'view',
1089
+ event_id: (0, utils_1.generateUUID)(),
1090
+ event_ts: new Date().toISOString(),
1091
+ // Link to search
1092
+ search_id: context.searchId,
1093
+ journey_id: context.journey_id,
1094
+ correlation_id: context.correlationId,
1095
+ session_id: context.sessionId || this.sessionId,
1096
+ // User context
1097
+ user_id: context.userId || this.userId,
1098
+ anonymous_id: context.anonId || this.anonId,
1099
+ // Item details
1100
+ clicked_item_id: item.id,
1101
+ position: index + 1,
1102
+ query: searchResponse.query || searchResponse.data?.data?.query,
1103
+ // Optional metadata
1104
+ analytics_tags: ['source:search', 'auto_tracked:true']
1105
+ }));
1106
+ try {
1107
+ await this.trackEvents(impressionEvents);
1108
+ this.logger.debug('Auto-tracked search impressions', { count: impressionEvents.length });
1109
+ }
1110
+ catch (error) {
1111
+ this.logger.error('Failed to auto-track impressions', {
1112
+ error: error.message,
1113
+ itemCount: items.length
1114
+ });
1115
+ throw error;
1116
+ }
1117
+ }
1029
1118
  /**
1030
1119
  * Get browser context (sync if cached, otherwise returns null)
1031
1120
  * Use collectBrowserContext() for async collection
@@ -1040,6 +1129,31 @@ class SeekoraClient {
1040
1129
  this.cachedBrowserContext = await this.contextCollector.collect();
1041
1130
  return this.cachedBrowserContext;
1042
1131
  }
1132
+ /**
1133
+ * Generate insert_id for event deduplication (industry standard)
1134
+ *
1135
+ * Format: {user_key}_{event_type}_{item_id}_{timestamp_window}
1136
+ * Similar to Segment, Amplitude, Mixpanel deduplication keys
1137
+ *
1138
+ * @param eventType - Event name (search, click, conversion, etc.)
1139
+ * @param itemId - Item ID (optional, for item-specific events)
1140
+ * @param userId - User ID or anonymous ID
1141
+ * @param window - Deduplication window in milliseconds
1142
+ * @returns insert_id string for backend deduplication
1143
+ */
1144
+ generateInsertId(eventType, itemId, userId, window) {
1145
+ // Round timestamp to nearest window (5 min default)
1146
+ const timestamp = Math.floor(Date.now() / window);
1147
+ // Format: userKey_eventType_itemId_timestampWindow
1148
+ // Item ID is optional (not all events have items)
1149
+ const parts = [
1150
+ userId.replace(/[^a-zA-Z0-9]/g, '_'), // Sanitize user ID
1151
+ eventType.replace(/[^a-zA-Z0-9]/g, '_'), // Sanitize event type
1152
+ itemId ? itemId.replace(/[^a-zA-Z0-9]/g, '_') : 'null',
1153
+ timestamp.toString()
1154
+ ];
1155
+ return parts.join('_');
1156
+ }
1043
1157
  /**
1044
1158
  * Build event payload with identifiers and browser context
1045
1159
  * Ensures user_id or anon_id is present, and sets correlation_id/search_id at top level
@@ -1121,6 +1235,26 @@ class SeekoraClient {
1121
1235
  payload.is_tablet = ctx.is_tablet ? 1 : 0;
1122
1236
  payload.is_touch_device = ctx.is_touch_device;
1123
1237
  }
1238
+ // Generate insert_id for deduplication (industry standard: Segment/Amplitude pattern)
1239
+ // Always enable for purchase events (revenue must be accurate)
1240
+ // Optional for other events based on config
1241
+ const isPurchaseEvent = payload.event_name === 'conversion' &&
1242
+ (payload.conversion_type === 'purchase' || payload.conversion_type === 'checkout_completed');
1243
+ if (this.enableDeduplication || isPurchaseEvent) {
1244
+ const userKey = payload.user_id || payload.anon_id || this.anonId;
1245
+ const eventType = payload.event_name || 'unknown';
1246
+ const itemId = payload.clicked_item_id;
1247
+ // Use longer window for purchase events (30 days)
1248
+ const window = isPurchaseEvent ? 2592000000 : this.deduplicationWindow;
1249
+ payload.insert_id = this.generateInsertId(eventType, itemId, userKey, window);
1250
+ this.logger.verbose('Generated insert_id for deduplication', {
1251
+ event_name: eventType,
1252
+ item_id: itemId,
1253
+ insert_id: payload.insert_id,
1254
+ is_purchase: isPurchaseEvent,
1255
+ window_ms: window
1256
+ });
1257
+ }
1124
1258
  return payload;
1125
1259
  }
1126
1260
  /**
@@ -1327,6 +1461,82 @@ class SeekoraClient {
1327
1461
  event_name: eventName,
1328
1462
  }, context);
1329
1463
  }
1464
+ /**
1465
+ * Track add to cart conversion event
1466
+ *
1467
+ * @param params - Add to cart parameters
1468
+ * @param params.itemId - Item ID that was added to cart
1469
+ * @param params.quantity - Quantity added (default: 1)
1470
+ * @param params.value - Item value/price
1471
+ * @param params.currency - Currency code (default: 'USD')
1472
+ * @param params.position - Position in search results (if from search)
1473
+ * @param params.searchContext - Search context for attribution
1474
+ */
1475
+ async trackAddToCart(params) {
1476
+ this.logger.verbose('Tracking add to cart event', {
1477
+ itemId: params.itemId,
1478
+ quantity: params.quantity,
1479
+ value: params.value
1480
+ });
1481
+ await this.trackEvent({
1482
+ event_name: 'conversion',
1483
+ conversion_type: 'add_to_cart',
1484
+ clicked_item_id: params.itemId,
1485
+ quantity: params.quantity || 1,
1486
+ value: params.value,
1487
+ currency: params.currency || 'USD',
1488
+ position: params.position,
1489
+ analytics_tags: ['action:add_to_cart'],
1490
+ }, params.searchContext);
1491
+ }
1492
+ /**
1493
+ * Track add to wishlist conversion event
1494
+ *
1495
+ * @param params - Add to wishlist parameters
1496
+ * @param params.itemId - Item ID that was added to wishlist
1497
+ * @param params.position - Position in search results (if from search)
1498
+ * @param params.searchContext - Search context for attribution
1499
+ */
1500
+ async trackAddToWishlist(params) {
1501
+ this.logger.verbose('Tracking add to wishlist event', {
1502
+ itemId: params.itemId
1503
+ });
1504
+ await this.trackEvent({
1505
+ event_name: 'conversion',
1506
+ conversion_type: 'wishlist',
1507
+ clicked_item_id: params.itemId,
1508
+ position: params.position,
1509
+ analytics_tags: ['action:add_to_wishlist'],
1510
+ }, params.searchContext);
1511
+ }
1512
+ /**
1513
+ * Track item share event
1514
+ *
1515
+ * @param params - Share parameters
1516
+ * @param params.itemId - Item ID that was shared
1517
+ * @param params.shareMethod - Share method (e.g., 'facebook', 'twitter', 'whatsapp', 'copy_link')
1518
+ * @param params.position - Position in search results (if from search)
1519
+ * @param params.searchContext - Search context for attribution
1520
+ */
1521
+ async trackShare(params) {
1522
+ this.logger.verbose('Tracking share event', {
1523
+ itemId: params.itemId,
1524
+ shareMethod: params.shareMethod
1525
+ });
1526
+ await this.trackEvent({
1527
+ event_name: 'custom',
1528
+ clicked_item_id: params.itemId,
1529
+ position: params.position,
1530
+ analytics_tags: [
1531
+ 'action:share',
1532
+ `share_method:${params.shareMethod || 'unknown'}`
1533
+ ],
1534
+ custom_json: JSON.stringify({
1535
+ share_method: params.shareMethod,
1536
+ action_type: 'share'
1537
+ }),
1538
+ }, params.searchContext);
1539
+ }
1330
1540
  /**
1331
1541
  * Validate an event payload before sending
1332
1542
  * Useful for debugging and ensuring events are properly formatted