@faststore/api 4.3.1-dev.0 → 4.4.0-dev.1

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.
@@ -0,0 +1,642 @@
1
+ import { parse } from 'cookie'
2
+
3
+ export interface IntelligentSearchFacet {
4
+ key: string
5
+ value: string
6
+ }
7
+
8
+ type FuzzyFacet = {
9
+ key: 'fuzzy'
10
+ value: '0' | '1' | 'auto'
11
+ }
12
+
13
+ type OperatorFacet = {
14
+ key: 'operator'
15
+ value: 'and' | 'or'
16
+ }
17
+
18
+ type SegmentParams = {
19
+ sc?: string | number
20
+ regionId?: string
21
+ country?: string
22
+ locale?: string
23
+ 'zip-code'?: string
24
+ coordinates?: string
25
+ pickupPoint?: string
26
+ deliveryZonesHash?: string
27
+ pickupPointHash?: string
28
+ utmSource?: string
29
+ utmCampaign?: string
30
+ utmiCampaign?: string
31
+ campaigns?: string
32
+ priceTables?: string
33
+ productClusterIds?: string
34
+ }
35
+
36
+ export type Sort =
37
+ | 'price:desc'
38
+ | 'price:asc'
39
+ | 'orders:desc'
40
+ | 'name:desc'
41
+ | 'name:asc'
42
+ | 'release:desc'
43
+ | 'discount:desc'
44
+ | ''
45
+
46
+ export type ProductIdentifierField = 'id' | 'slug' | 'ean' | 'reference' | 'sku'
47
+
48
+ export type IntelligentSearchEndpoint =
49
+ | 'product-search'
50
+ | 'facets'
51
+ | 'catalog-count'
52
+ | 'products'
53
+ | 'search-suggestions'
54
+ | 'top-searches'
55
+
56
+ export interface IntelligentSearchDefaults {
57
+ salesChannel?: string | number
58
+ regionId?: string
59
+ locale: string
60
+ hideUnavailableItems?: boolean
61
+ simulationBehavior?: 'default' | 'skip' | 'only1P'
62
+ showSponsored?: boolean
63
+ }
64
+
65
+ export interface IntelligentSearchRequestArgs {
66
+ query?: string
67
+ page?: number
68
+ count?: number
69
+ sort?: Sort
70
+ selectedFacets?: IntelligentSearchFacet[]
71
+ showInvisibleItems?: boolean
72
+ hideUnavailableItems?: boolean
73
+ sponsoredCount?: number
74
+ allowRedirect?: boolean
75
+ field?: ProductIdentifierField
76
+ value?: string
77
+ }
78
+
79
+ export interface IntelligentSearchRequestInput {
80
+ endpoint: IntelligentSearchEndpoint
81
+ /** Decoded vtex_segment object (params + extraFacets are extracted internally). */
82
+ segment?: Record<string, unknown>
83
+ defaults?: IntelligentSearchDefaults
84
+ args?: IntelligentSearchRequestArgs
85
+ }
86
+
87
+ export interface IntelligentSearchRequest {
88
+ /** Attribute path segment only (no base URL or endpoint prefix). */
89
+ path: string
90
+ params: URLSearchParams
91
+ toString(): string
92
+ }
93
+
94
+ const FUZZY_KEY = 'fuzzy'
95
+ const OPERATOR_KEY = 'operator'
96
+ const PICKUP_POINT_KEY = 'pickupPoint'
97
+ const SHIPPING_KEY = 'shipping'
98
+ const DELIVERY_OPTIONS_KEY = 'delivery-options'
99
+ const IN_STOCK_KEY = 'in-stock'
100
+ const POLICY_KEY = 'trade-policy'
101
+ const REGION_KEY = 'region-id'
102
+
103
+ const PATH_EXCLUDED_KEYS = new Set([
104
+ POLICY_KEY,
105
+ REGION_KEY,
106
+ FUZZY_KEY,
107
+ OPERATOR_KEY,
108
+ PICKUP_POINT_KEY,
109
+ ])
110
+
111
+ const SHIPPING_FACET_KEYS = new Set([
112
+ 'zip-code',
113
+ 'pickupPoint',
114
+ 'country',
115
+ 'coordinates',
116
+ 'deliveryZonesHash',
117
+ 'pickupPointsHash',
118
+ ])
119
+
120
+ const QUERY_PARAM_FACET_KEYS = new Set(['productClusterIds'])
121
+
122
+ const encodeSafeURI = (uri: string) => encodeURI(decodeURI(uri))
123
+
124
+ const removeDiacriticsFromURL = (url: string) =>
125
+ encodeURIComponent(
126
+ decodeURIComponent(url)
127
+ .normalize('NFD')
128
+ // biome-ignore lint/suspicious/noMisleadingCharacterClass: After NFD normalization, combining marks are separated and can be matched individually
129
+ .replaceAll(/[\u0300-\u036f]/g, '')
130
+ )
131
+
132
+ const PICKUP_IN_POINT_SUFFIX_RE = /^pickup-in-point-(.+)$/
133
+
134
+ /**
135
+ * Reads and decodes the `vtex_segment` cookie. Does not create a segment.
136
+ * Returns an empty object when the cookie is absent or invalid.
137
+ */
138
+ export function parseSegmentCookie(
139
+ cookieHeader?: string
140
+ ): Record<string, unknown> {
141
+ if (!cookieHeader) {
142
+ return {}
143
+ }
144
+
145
+ try {
146
+ const cookies = parse(cookieHeader)
147
+ const segmentToken = cookies.vtex_segment
148
+
149
+ if (!segmentToken) {
150
+ return {}
151
+ }
152
+
153
+ const decoded = Buffer.from(segmentToken, 'base64').toString('utf-8')
154
+
155
+ return JSON.parse(decoded) as Record<string, unknown>
156
+ } catch {
157
+ return {}
158
+ }
159
+ }
160
+
161
+ function parseSegmentFacetsString(facetsStr: string): {
162
+ shipping: Record<string, string>
163
+ queryParams: Record<string, string>
164
+ extraFacets: IntelligentSearchFacet[]
165
+ } {
166
+ const shipping: Record<string, string> = {}
167
+ const queryParams: Record<string, string> = {}
168
+ const extraFacets: IntelligentSearchFacet[] = []
169
+
170
+ for (const pair of facetsStr.split(';')) {
171
+ const eqIdx = pair.indexOf('=')
172
+
173
+ if (eqIdx < 0) continue
174
+
175
+ const key = pair.slice(0, eqIdx)
176
+ const value = pair.slice(eqIdx + 1)
177
+
178
+ if (!key || !value) continue
179
+
180
+ if (SHIPPING_FACET_KEYS.has(key)) {
181
+ shipping[key] = value
182
+ } else if (QUERY_PARAM_FACET_KEYS.has(key)) {
183
+ queryParams[key] = value
184
+ } else {
185
+ extraFacets.push({ key, value })
186
+ }
187
+ }
188
+
189
+ return { shipping, queryParams, extraFacets }
190
+ }
191
+
192
+ function extractSegmentData(segment: Record<string, unknown>): {
193
+ segmentParams: SegmentParams
194
+ extraFacets: IntelligentSearchFacet[]
195
+ } {
196
+ const facetsStr = typeof segment.facets === 'string' ? segment.facets : ''
197
+ const { shipping, queryParams, extraFacets } =
198
+ parseSegmentFacetsString(facetsStr)
199
+
200
+ return {
201
+ segmentParams: {
202
+ sc: segment.channel as string | number | undefined,
203
+ regionId: segment.regionId as string | undefined,
204
+ country: (segment.countryCode as string | undefined) ?? shipping.country,
205
+ locale: segment.cultureInfo as string | undefined,
206
+ 'zip-code': shipping['zip-code'],
207
+ coordinates: shipping.coordinates,
208
+ pickupPoint: shipping.pickupPoint,
209
+ deliveryZonesHash: shipping.deliveryZonesHash,
210
+ pickupPointHash: shipping.pickupPointsHash,
211
+ utmSource: (segment.utm_source as string | undefined) ?? undefined,
212
+ utmCampaign: (segment.utm_campaign as string | undefined) ?? undefined,
213
+ utmiCampaign: (segment.utmi_campaign as string | undefined) ?? undefined,
214
+ campaigns:
215
+ typeof segment.campaigns === 'string' ? segment.campaigns : undefined,
216
+ priceTables: (segment.priceTables as string | undefined) ?? undefined,
217
+ productClusterIds: queryParams.productClusterIds,
218
+ },
219
+ extraFacets,
220
+ }
221
+ }
222
+
223
+ function appendSegmentParams(
224
+ params: URLSearchParams,
225
+ segmentParams: SegmentParams
226
+ ) {
227
+ const entries: Array<[string, string | number | undefined]> = [
228
+ ['sc', segmentParams.sc],
229
+ ['regionId', segmentParams.regionId],
230
+ ['country', segmentParams.country],
231
+ ['locale', segmentParams.locale],
232
+ ['zip-code', segmentParams['zip-code']],
233
+ ['coordinates', segmentParams.coordinates],
234
+ ['pickupPoint', segmentParams.pickupPoint],
235
+ ['deliveryZonesHash', segmentParams.deliveryZonesHash],
236
+ ['pickupPointHash', segmentParams.pickupPointHash],
237
+ ['utmSource', segmentParams.utmSource],
238
+ ['utmCampaign', segmentParams.utmCampaign],
239
+ ['utmiCampaign', segmentParams.utmiCampaign],
240
+ ['campaigns', segmentParams.campaigns],
241
+ ['priceTables', segmentParams.priceTables],
242
+ ['productClusterId', segmentParams.productClusterIds],
243
+ ]
244
+
245
+ for (const [key, value] of entries) {
246
+ if (value !== undefined && value !== null && value !== '') {
247
+ params.append(key, String(value))
248
+ }
249
+ }
250
+ }
251
+
252
+ function extractPickupPointIdFromShippingFacetValue(
253
+ value: string
254
+ ): string | undefined {
255
+ const match = PICKUP_IN_POINT_SUFFIX_RE.exec(value)
256
+
257
+ return match?.[1]
258
+ }
259
+
260
+ function extractPickupPointIdFromPathShippingFacet(
261
+ selectedFacetsFromPath: IntelligentSearchFacet[]
262
+ ): string | undefined {
263
+ const shipping = selectedFacetsFromPath.find((f) => f.key === 'shipping')
264
+ const rawId = shipping
265
+ ? extractPickupPointIdFromShippingFacetValue(shipping.value)
266
+ : undefined
267
+
268
+ if (rawId === undefined) {
269
+ return undefined
270
+ }
271
+
272
+ try {
273
+ return decodeURIComponent(rawId)
274
+ } catch {
275
+ return rawId
276
+ }
277
+ }
278
+
279
+ function mergeSegmentParamsWithPickupFromPath(
280
+ segmentParams: SegmentParams | undefined,
281
+ selectedFacetsFromPath: IntelligentSearchFacet[]
282
+ ): SegmentParams | undefined {
283
+ const pathPickupId = extractPickupPointIdFromPathShippingFacet(
284
+ selectedFacetsFromPath
285
+ )
286
+
287
+ if (pathPickupId === undefined) {
288
+ return segmentParams
289
+ }
290
+
291
+ return {
292
+ ...segmentParams,
293
+ pickupPoint: pathPickupId,
294
+ }
295
+ }
296
+
297
+ function normalizePickupInPointShippingFacets(
298
+ selectedFacets: IntelligentSearchFacet[]
299
+ ): IntelligentSearchFacet[] {
300
+ return selectedFacets.map((facet) => {
301
+ if (facet.key !== 'shipping') {
302
+ return facet
303
+ }
304
+
305
+ const id = extractPickupPointIdFromShippingFacetValue(facet.value)
306
+
307
+ if (id === undefined) {
308
+ return facet
309
+ }
310
+
311
+ return { ...facet, value: 'pickup-in-point' }
312
+ })
313
+ }
314
+
315
+ function concatSelectedFacets(
316
+ selectedFacets: IntelligentSearchFacet[],
317
+ selectedFacetsFromSegment: IntelligentSearchFacet[]
318
+ ): IntelligentSearchFacet[] {
319
+ let result = [...selectedFacets]
320
+ const hasShipping = result.some((f) => f.key === 'shipping')
321
+
322
+ for (const facet of selectedFacetsFromSegment) {
323
+ if (!hasShipping || facet.key !== 'shipping') {
324
+ result.push(facet)
325
+ }
326
+ }
327
+
328
+ if (result.some((f) => f.key === 'shipping' && f.value === 'ignore')) {
329
+ result = result.filter((f) => f.key !== 'shipping')
330
+ }
331
+
332
+ return normalizePickupInPointShippingFacets(result)
333
+ }
334
+
335
+ function buildAttributePath(selectedFacets: IntelligentSearchFacet[]) {
336
+ return selectedFacets.reduce((attributePath, facet) => {
337
+ let { key, value } = facet
338
+
339
+ if (key === 'priceRange') {
340
+ key = 'price'
341
+ value = value.replace(' TO ', ':')
342
+ }
343
+
344
+ return key === 'ft'
345
+ ? attributePath
346
+ : `${attributePath}${encodeSafeURI(key)}/${removeDiacriticsFromURL(encodeSafeURI(value)).replaceAll(/ |%20/g, '-')}/`
347
+ }, '')
348
+ }
349
+
350
+ const isFuzzyFacet = (facet: IntelligentSearchFacet): facet is FuzzyFacet =>
351
+ facet.key === 'fuzzy' &&
352
+ (facet.value === '0' || facet.value === '1' || facet.value === 'auto')
353
+
354
+ const isOperatorFacet = (
355
+ facet: IntelligentSearchFacet
356
+ ): facet is OperatorFacet =>
357
+ facet.key === 'operator' && (facet.value === 'and' || facet.value === 'or')
358
+
359
+ function resolveSegmentData(
360
+ segment: Record<string, unknown> | undefined,
361
+ defaults: IntelligentSearchDefaults | undefined
362
+ ): { segmentParams: SegmentParams; extraFacets: IntelligentSearchFacet[] } {
363
+ const { segmentParams, extraFacets } = extractSegmentData(segment ?? {})
364
+
365
+ // Server-provided defaults are authoritative for sales channel/region: the
366
+ // raw `vtex_segment` cookie is client-supplied base64 JSON and must not be
367
+ // able to override the trusted server context. Only fall back to the cookie
368
+ // values when no default is provided.
369
+ const sc = defaults?.salesChannel ?? segmentParams.sc
370
+ const regionId = defaults?.regionId ?? segmentParams.regionId
371
+ const locale = defaults?.locale ?? segmentParams.locale ?? ''
372
+
373
+ return {
374
+ segmentParams: {
375
+ ...segmentParams,
376
+ ...(sc ? { sc } : {}),
377
+ ...(regionId ? { regionId } : {}),
378
+ locale,
379
+ },
380
+ extraFacets,
381
+ }
382
+ }
383
+
384
+ function preparePathFacets(
385
+ facets: IntelligentSearchFacet[],
386
+ extraFacets: IntelligentSearchFacet[]
387
+ ): IntelligentSearchFacet[] {
388
+ const pathFacets = facets.filter(({ key }) => !PATH_EXCLUDED_KEYS.has(key))
389
+
390
+ const shippingFacet =
391
+ facets.find(
392
+ ({ key, value }) =>
393
+ key === SHIPPING_KEY && value !== 'all-delivery-methods'
394
+ ) ?? null
395
+
396
+ const deliveryOptionsFacet =
397
+ facets.find(
398
+ ({ key, value }) =>
399
+ key === DELIVERY_OPTIONS_KEY && value !== 'all-delivery-options'
400
+ ) ?? null
401
+
402
+ const withShippingFacets = [...pathFacets]
403
+
404
+ // `pathFacets` may already contain the shipping/delivery-options facets, so
405
+ // only push them when they are not already present to avoid duplicated path
406
+ // segments like `shipping/delivery/shipping/delivery/`.
407
+ const hasShippingInPath = withShippingFacets.some(
408
+ ({ key }) => key === SHIPPING_KEY
409
+ )
410
+ const hasDeliveryOptionsInPath = withShippingFacets.some(
411
+ ({ key }) => key === DELIVERY_OPTIONS_KEY
412
+ )
413
+
414
+ if (shippingFacet !== null && !hasShippingInPath) {
415
+ withShippingFacets.push(shippingFacet)
416
+ }
417
+
418
+ if (deliveryOptionsFacet !== null && !hasDeliveryOptionsInPath) {
419
+ withShippingFacets.push(deliveryOptionsFacet)
420
+ }
421
+
422
+ return concatSelectedFacets(withShippingFacets, extraFacets)
423
+ }
424
+
425
+ function addSearchParamsFacets(
426
+ facets: IntelligentSearchFacet[],
427
+ params: URLSearchParams
428
+ ) {
429
+ const fuzzyFacet = facets.find(({ key }) => key === FUZZY_KEY) ?? null
430
+ const operatorFacet = facets.find(({ key }) => key === OPERATOR_KEY) ?? null
431
+ const pickupPointFacet =
432
+ facets.find(({ key }) => key === PICKUP_POINT_KEY) ?? null
433
+
434
+ if (fuzzyFacet && isFuzzyFacet(fuzzyFacet)) {
435
+ params.append(FUZZY_KEY, fuzzyFacet.value)
436
+ }
437
+
438
+ if (operatorFacet && isOperatorFacet(operatorFacet)) {
439
+ params.append(OPERATOR_KEY, operatorFacet.value)
440
+ }
441
+
442
+ if (pickupPointFacet) {
443
+ params.append(PICKUP_POINT_KEY, pickupPointFacet.value)
444
+ }
445
+ }
446
+
447
+ interface SearchLikeParams {
448
+ includePagination: boolean
449
+ includeSort: boolean
450
+ }
451
+
452
+ function buildSearchLikeParams(
453
+ args: IntelligentSearchRequestArgs,
454
+ defaults: IntelligentSearchDefaults | undefined,
455
+ segmentData: {
456
+ segmentParams: SegmentParams
457
+ extraFacets: IntelligentSearchFacet[]
458
+ },
459
+ options: SearchLikeParams
460
+ ): { params: URLSearchParams; path: string } {
461
+ const {
462
+ query = '',
463
+ page,
464
+ count,
465
+ sort,
466
+ selectedFacets = [],
467
+ showInvisibleItems,
468
+ sponsoredCount,
469
+ hideUnavailableItems: searchHideUnavailableItems,
470
+ allowRedirect = false,
471
+ } = args
472
+
473
+ const params = new URLSearchParams()
474
+
475
+ if (options.includePagination && page !== undefined && count !== undefined) {
476
+ const from = page * count
477
+ const to = count === 0 ? from : from + count - 1
478
+
479
+ params.append('from', from.toString())
480
+ params.append('to', to.toString())
481
+ }
482
+
483
+ if (query) {
484
+ params.append('query', query)
485
+ }
486
+
487
+ if (options.includeSort && sort) {
488
+ params.append('sort', sort)
489
+ }
490
+
491
+ const mergedSegmentParams = mergeSegmentParamsWithPickupFromPath(
492
+ segmentData.segmentParams,
493
+ selectedFacets
494
+ )
495
+
496
+ appendSegmentParams(params, mergedSegmentParams ?? segmentData.segmentParams)
497
+ addSearchParamsFacets(selectedFacets, params)
498
+
499
+ if (showInvisibleItems) {
500
+ params.append('show-invisible-items', 'true')
501
+ }
502
+
503
+ if (defaults?.hideUnavailableItems !== undefined) {
504
+ const inStockFacet = selectedFacets.find(({ key }) => key === IN_STOCK_KEY)
505
+ const shouldHideUnavailableItems = inStockFacet
506
+ ? inStockFacet.value
507
+ : (searchHideUnavailableItems?.toString() ??
508
+ defaults.hideUnavailableItems.toString())
509
+
510
+ params.append('hideUnavailableItems', shouldHideUnavailableItems)
511
+ }
512
+
513
+ if (defaults?.simulationBehavior !== undefined) {
514
+ params.append('simulationBehavior', defaults.simulationBehavior.toString())
515
+ }
516
+
517
+ if (defaults?.showSponsored !== undefined) {
518
+ params.append('showSponsored', defaults.showSponsored.toString())
519
+ }
520
+
521
+ if (sponsoredCount !== undefined) {
522
+ params.append('sponsoredCount', sponsoredCount.toString())
523
+ }
524
+
525
+ if (allowRedirect !== undefined) {
526
+ params.append('allowRedirect', allowRedirect.toString())
527
+ }
528
+
529
+ const path = buildAttributePath(
530
+ preparePathFacets(selectedFacets, segmentData.extraFacets)
531
+ )
532
+
533
+ return { params, path }
534
+ }
535
+
536
+ function buildProductsParams(
537
+ args: IntelligentSearchRequestArgs,
538
+ segmentParams: SegmentParams
539
+ ): URLSearchParams {
540
+ const { field, value, hideUnavailableItems, showInvisibleItems } = args
541
+
542
+ const params = new URLSearchParams({
543
+ field: field ?? '',
544
+ value: value ?? '',
545
+ })
546
+
547
+ appendSegmentParams(params, segmentParams)
548
+
549
+ if (hideUnavailableItems) {
550
+ params.append('hideUnavailableItems', 'true')
551
+ }
552
+
553
+ if (showInvisibleItems) {
554
+ params.append('show-invisible-items', 'true')
555
+ }
556
+
557
+ return params
558
+ }
559
+
560
+ function createRequest(
561
+ path: string,
562
+ params: URLSearchParams
563
+ ): IntelligentSearchRequest {
564
+ return {
565
+ path,
566
+ params,
567
+ toString() {
568
+ const qs = params.toString()
569
+
570
+ if (!path) {
571
+ return qs ? `?${qs}` : ''
572
+ }
573
+
574
+ return qs ? `${path}?${qs}` : path
575
+ },
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Pure builder for intelligent-search v1 path + query params.
581
+ * Callers assemble the full URL: `/api/intelligent-search/v1/${endpoint}/${request.path}?${request.params}`.
582
+ */
583
+ export function buildIntelligentSearchRequest(
584
+ input: IntelligentSearchRequestInput
585
+ ): IntelligentSearchRequest {
586
+ const { endpoint, segment, defaults, args = {} } = input
587
+ const segmentData = resolveSegmentData(segment, defaults)
588
+
589
+ switch (endpoint) {
590
+ case 'product-search':
591
+ case 'facets': {
592
+ const { params, path } = buildSearchLikeParams(
593
+ args,
594
+ defaults,
595
+ segmentData,
596
+ { includePagination: true, includeSort: true }
597
+ )
598
+
599
+ return createRequest(path, params)
600
+ }
601
+
602
+ case 'catalog-count': {
603
+ const { params, path } = buildSearchLikeParams(
604
+ args,
605
+ defaults,
606
+ segmentData,
607
+ { includePagination: false, includeSort: false }
608
+ )
609
+
610
+ return createRequest(path, params)
611
+ }
612
+
613
+ case 'products': {
614
+ const params = buildProductsParams(args, segmentData.segmentParams)
615
+
616
+ return createRequest('', params)
617
+ }
618
+
619
+ case 'search-suggestions': {
620
+ const params = new URLSearchParams({
621
+ query: args.query?.toString() ?? '',
622
+ locale: defaults?.locale ?? segmentData.segmentParams.locale ?? '',
623
+ })
624
+
625
+ return createRequest('', params)
626
+ }
627
+
628
+ case 'top-searches': {
629
+ const params = new URLSearchParams({
630
+ locale: defaults?.locale ?? segmentData.segmentParams.locale ?? '',
631
+ })
632
+
633
+ return createRequest('', params)
634
+ }
635
+
636
+ default: {
637
+ const _exhaustive: never = endpoint
638
+
639
+ throw new Error(`Unknown intelligent-search endpoint: ${_exhaustive}`)
640
+ }
641
+ }
642
+ }