@faststore/api 4.5.0-dev.3 → 4.5.0-dev.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cjs/index.js +95 -44
  2. package/dist/cjs/index.js.map +1 -1
  3. package/dist/es/index.mjs +1088 -837
  4. package/dist/es/index.mjs.map +1 -1
  5. package/dist/src/__generated__/schema.d.ts +44 -3
  6. package/dist/src/__generated__/schema.d.ts.map +1 -1
  7. package/dist/src/platforms/vtex/clients/catalog/index.d.ts +34 -0
  8. package/dist/src/platforms/vtex/clients/catalog/index.d.ts.map +1 -0
  9. package/dist/src/platforms/vtex/clients/commerce/index.d.ts +6 -0
  10. package/dist/src/platforms/vtex/clients/commerce/index.d.ts.map +1 -1
  11. package/dist/src/platforms/vtex/clients/commerce/types/ByLinkId.d.ts +45 -0
  12. package/dist/src/platforms/vtex/clients/commerce/types/ByLinkId.d.ts.map +1 -0
  13. package/dist/src/platforms/vtex/clients/index.d.ts +8 -0
  14. package/dist/src/platforms/vtex/clients/index.d.ts.map +1 -1
  15. package/dist/src/platforms/vtex/clients/search/index.d.ts.map +1 -1
  16. package/dist/src/platforms/vtex/index.d.ts +12 -13
  17. package/dist/src/platforms/vtex/index.d.ts.map +1 -1
  18. package/dist/src/platforms/vtex/loaders/collection.d.ts +32 -3
  19. package/dist/src/platforms/vtex/loaders/collection.d.ts.map +1 -1
  20. package/dist/src/platforms/vtex/loaders/index.d.ts +2 -2
  21. package/dist/src/platforms/vtex/loaders/index.d.ts.map +1 -1
  22. package/dist/src/platforms/vtex/resolvers/collection.d.ts +20 -2
  23. package/dist/src/platforms/vtex/resolvers/collection.d.ts.map +1 -1
  24. package/dist/src/platforms/vtex/resolvers/getOrderEntryOperation.d.ts.map +1 -1
  25. package/dist/src/platforms/vtex/resolvers/index.d.ts +3 -13
  26. package/dist/src/platforms/vtex/resolvers/index.d.ts.map +1 -1
  27. package/dist/src/platforms/vtex/resolvers/product.d.ts +13 -1
  28. package/dist/src/platforms/vtex/resolvers/product.d.ts.map +1 -1
  29. package/dist/src/platforms/vtex/resolvers/query.d.ts +8 -17
  30. package/dist/src/platforms/vtex/resolvers/query.d.ts.map +1 -1
  31. package/dist/src/platforms/vtex/utils/localization.d.ts +28 -0
  32. package/dist/src/platforms/vtex/utils/localization.d.ts.map +1 -0
  33. package/package.json +3 -3
  34. package/src/__generated__/schema.ts +46 -3
  35. package/src/platforms/vtex/clients/catalog/index.ts +52 -0
  36. package/src/platforms/vtex/clients/commerce/index.ts +71 -0
  37. package/src/platforms/vtex/clients/commerce/types/ByLinkId.ts +46 -0
  38. package/src/platforms/vtex/clients/index.ts +3 -0
  39. package/src/platforms/vtex/clients/search/index.ts +16 -4
  40. package/src/platforms/vtex/index.ts +17 -2
  41. package/src/platforms/vtex/loaders/collection.ts +99 -25
  42. package/src/platforms/vtex/loaders/index.ts +2 -1
  43. package/src/platforms/vtex/resolvers/collection.ts +173 -66
  44. package/src/platforms/vtex/resolvers/product.ts +160 -11
  45. package/src/platforms/vtex/resolvers/query.ts +148 -56
  46. package/src/platforms/vtex/typeDefs/collection.graphql +33 -1
  47. package/src/platforms/vtex/typeDefs/product.graphql +19 -0
  48. package/src/platforms/vtex/utils/localization.ts +45 -0
@@ -18,8 +18,6 @@ import type {
18
18
  StoreContract,
19
19
  UserOrderFromList,
20
20
  } from '../../../__generated__/schema'
21
- import { getOrderEntryOperation } from './getOrderEntryOperation'
22
- import { getOrderFormItems } from './getOrderFormItems'
23
21
  import { recommendations } from './recommendations'
24
22
  import {
25
23
  BadRequestError,
@@ -33,6 +31,10 @@ import type { ProfileAddress } from '../clients/commerce/types/Profile'
33
31
  import type { SearchArgs } from '../clients/search'
34
32
  import type { ProductSearchResult } from '../clients/search/types/ProductSearchResult'
35
33
  import type { GraphqlContext } from '../index'
34
+ import type {
35
+ ByLinkIdBrandRoot,
36
+ ByLinkIdCategoryRoot,
37
+ } from '../loaders/collection'
36
38
  import { extractRuleForAuthorization } from '../utils/commercialAuth'
37
39
  import {
38
40
  mapSessionContractsToStoreContracts,
@@ -42,7 +44,7 @@ import {
42
44
  } from '../utils/contract'
43
45
  import { mutateChannelContext, mutateLocaleContext } from '../utils/contex'
44
46
  import { getAuthCookie, parseJwt } from '../utils/cookies'
45
- import { enhanceSku } from '../utils/enhanceSku'
47
+ import { enhanceSku, type EnhancedSku } from '../utils/enhanceSku'
46
48
  import {
47
49
  findChannel,
48
50
  findCrossSelling,
@@ -51,10 +53,36 @@ import {
51
53
  findSlug,
52
54
  transformSelectedFacet,
53
55
  } from '../utils/facets'
56
+ import { getCatalogLocale, isLocalizationEnabled } from '../utils/localization'
54
57
  import { isValidSkuId, pickBestSku } from '../utils/sku'
58
+ import { slugify } from '../utils/slugify'
55
59
  import { SORT_MAP } from '../utils/sort'
56
60
  import { FACET_CROSS_SELLING_MAP } from './../utils/facets'
57
61
  import { StoreCollection } from './collection'
62
+ import { getOrderEntryOperation } from './getOrderEntryOperation'
63
+ import { getOrderFormItems } from './getOrderFormItems'
64
+ import { getLocalizedProductEntry } from './product'
65
+
66
+ /**
67
+ * Validates that a slug mismatch between IS linkText and the requested slug is
68
+ * actually a localized slug match. Fetches the localized product entry from
69
+ * Catalog Dataplane (with request-scoped caching) and checks whether the slug
70
+ * prefix matches the localized linkId for the current locale.
71
+ *
72
+ * Returns true if the slug is a valid localized match, false otherwise
73
+ * (including when the Dataplane API is unavailable).
74
+ */
75
+ async function isLocalizedSlugMatch(
76
+ ctx: GraphqlContext,
77
+ slug: string,
78
+ productGroupID: string,
79
+ locale: string
80
+ ): Promise<boolean> {
81
+ const slugPrefix = slug.slice(0, slug.lastIndexOf('-'))
82
+ const entry = await getLocalizedProductEntry(ctx, productGroupID, locale)
83
+
84
+ return entry?.linkId === slugPrefix
85
+ }
58
86
 
59
87
  const INVALID_SKU_ID_ERROR = 'Invalid SkuId'
60
88
  const SLUG_MISMATCH_ERROR =
@@ -66,6 +94,80 @@ const shouldFallbackToProductRoute = (error: unknown) =>
66
94
  (error.message === INVALID_SKU_ID_ERROR ||
67
95
  error.message.startsWith(SLUG_MISMATCH_ERROR)))
68
96
 
97
+ /**
98
+ * Here be dragons 🦄🦄🦄
99
+ *
100
+ * In some cases, the slug has a valid skuId for a different product. This
101
+ * guards that the fetched sku is the one we actually asked for, throwing
102
+ * SLUG_MISMATCH_ERROR (caught by the caller) when it isn't.
103
+ *
104
+ * When localization is enabled, the slug prefix may be a localized LinkId
105
+ * that differs from the IS linkText (always in the default locale). In that
106
+ * case we validate against the Catalog Dataplane API before rejecting the
107
+ * slug.
108
+ */
109
+ async function assertSkuMatchesSlug(
110
+ ctx: GraphqlContext,
111
+ sku: EnhancedSku,
112
+ slug: string | null | undefined,
113
+ locale: string | null | undefined
114
+ ): Promise<void> {
115
+ const { linkText, productId } = sku.isVariantOf
116
+
117
+ if (!slug || !linkText || slug.startsWith(linkText)) {
118
+ return
119
+ }
120
+
121
+ const isValidLocalizedMatch =
122
+ isLocalizationEnabled(ctx) &&
123
+ locale &&
124
+ isValidSkuId(slug.split('-').pop() ?? '') &&
125
+ (await isLocalizedSlugMatch(ctx, slug, productId, locale))
126
+
127
+ if (isValidLocalizedMatch) {
128
+ return
129
+ }
130
+
131
+ throw new Error(`${SLUG_MISMATCH_ERROR} slug: ${slug}, linkText: ${linkText}`)
132
+ }
133
+
134
+ /**
135
+ * Fallback used when the sku/slug lookup above fails (invalid skuId, not
136
+ * found, or slug mismatch): resolves the slug through the legacy pagetype
137
+ * route and fetches the product from Intelligent Search by id instead.
138
+ */
139
+ async function fetchProductBySlugFallback(
140
+ ctx: GraphqlContext,
141
+ slug: string | null | undefined
142
+ ): Promise<EnhancedSku> {
143
+ if (slug == null) {
144
+ throw new BadRequestError('Missing slug or id')
145
+ }
146
+
147
+ const {
148
+ clients: { commerce, search },
149
+ } = ctx
150
+
151
+ const route = await commerce.catalog.portal.pagetype(`${slug}/p`)
152
+
153
+ if (route.pageType !== 'Product' || !route.id) {
154
+ throw new NotFoundError(`No product found for slug ${slug}`)
155
+ }
156
+
157
+ const product = await search
158
+ .fetchProduct({
159
+ field: 'id',
160
+ value: String(route.id),
161
+ })
162
+ .catch(() => null)
163
+
164
+ if (!product) {
165
+ throw new NotFoundError(`No product found for id ${route.id}`)
166
+ }
167
+
168
+ return enhanceSku(pickBestSku(product.items), product)
169
+ }
170
+
69
171
  export const Query = {
70
172
  product: async (
71
173
  _: unknown,
@@ -88,7 +190,6 @@ export const Query = {
88
190
 
89
191
  const {
90
192
  loaders: { skuLoader },
91
- clients: { commerce, search },
92
193
  } = ctx
93
194
 
94
195
  try {
@@ -100,22 +201,7 @@ export const Query = {
100
201
 
101
202
  const sku = await skuLoader.load(skuId)
102
203
 
103
- /**
104
- * Here be dragons 🦄🦄🦄
105
- *
106
- * In some cases, the slug has a valid skuId for a different
107
- * product. This condition makes sure that the fetched sku
108
- * is the one we actually asked for
109
- * */
110
- if (
111
- slug &&
112
- sku.isVariantOf.linkText &&
113
- !slug.startsWith(sku.isVariantOf.linkText)
114
- ) {
115
- throw new Error(
116
- `Slug was set but the fetched sku does not satisfy the slug condition. slug: ${slug}, linkText: ${sku.isVariantOf.linkText}`
117
- )
118
- }
204
+ await assertSkuMatchesSlug(ctx, sku, slug, locale)
119
205
 
120
206
  return sku
121
207
  } catch (err) {
@@ -123,30 +209,7 @@ export const Query = {
123
209
  throw err
124
210
  }
125
211
 
126
- if (slug == null) {
127
- throw new BadRequestError('Missing slug or id')
128
- }
129
-
130
- const route = await commerce.catalog.portal.pagetype(`${slug}/p`)
131
-
132
- if (route.pageType !== 'Product' || !route.id) {
133
- throw new NotFoundError(`No product found for slug ${slug}`)
134
- }
135
-
136
- const product = await search
137
- .fetchProduct({
138
- field: 'id',
139
- value: String(route.id),
140
- })
141
- .catch(() => null)
142
-
143
- if (!product) {
144
- throw new NotFoundError(`No product found for id ${route.id}`)
145
- }
146
-
147
- const sku = pickBestSku(product.items)
148
-
149
- return enhanceSku(sku, product)
212
+ return fetchProductBySlugFallback(ctx, slug)
150
213
  }
151
214
  },
152
215
  collection: (
@@ -158,7 +221,14 @@ export const Query = {
158
221
  loaders: { collectionLoader },
159
222
  } = ctx
160
223
 
161
- return collectionLoader.load(slug)
224
+ // The request locale is set on ctx.storage.locale by the core `execute`
225
+ // wrapper (from Next.js i18n) rather than a GraphQL argument, so overridable
226
+ // fragments (API extensions) that also select `collection` keep merging
227
+ // without argument conflicts.
228
+ return collectionLoader.load({
229
+ slug,
230
+ locale: getCatalogLocale(ctx),
231
+ })
162
232
  },
163
233
  search: async (
164
234
  _: unknown,
@@ -323,25 +393,47 @@ export const Query = {
323
393
  commerce.catalog.category.tree(),
324
394
  ])
325
395
 
326
- const categories: Array<CategoryTree & { level: number }> = []
327
- const dfs = (node: CategoryTree, level: number) => {
328
- categories.push({ ...node, level })
396
+ // Flatten the category tree. parentId is tracked per node so
397
+ // the type resolver can correctly classify Departments (fatherCategoryId: null)
398
+ // vs Categories (fatherCategoryId: number).
399
+ const categoryRoots: ByLinkIdCategoryRoot[] = []
400
+ const dfs = (node: CategoryTree, parentId: number | null) => {
401
+ categoryRoots.push({
402
+ id: node.id,
403
+ name: node.name,
404
+ fatherCategoryId: parentId,
405
+ linkId: slugify(node.name),
406
+ title: node.Title,
407
+ description: null,
408
+ metaTagDescription: node.MetaTagDescription,
409
+ availableLinkIds: null,
410
+ entityType: 'category' as const,
411
+ slug: new URL(node.url).pathname.slice(1).toLowerCase(),
412
+ })
329
413
 
330
414
  for (const child of node.children) {
331
- dfs(child, level + 1)
415
+ dfs(child, node.id)
332
416
  }
333
417
  }
334
418
 
335
419
  for (const node of tree) {
336
- dfs(node, 0)
420
+ dfs(node, null)
337
421
  }
338
422
 
339
- const collections = [
340
- ...brands
341
- .filter((brand) => brand.isActive)
342
- .map((x) => ({ ...x, type: 'brand' })),
343
- ...categories,
344
- ]
423
+ const brandRoots: ByLinkIdBrandRoot[] = brands
424
+ .filter((brand) => brand.isActive)
425
+ .map((brand) => ({
426
+ id: brand.id,
427
+ name: brand.name,
428
+ linkId: slugify(brand.name),
429
+ title: brand.title,
430
+ description: null,
431
+ metaTagDescription: brand.metaTagDescription,
432
+ availableLinkIds: null,
433
+ entityType: 'brand' as const,
434
+ }))
435
+
436
+ const collections = [...brandRoots, ...categoryRoots]
345
437
 
346
438
  const validCollections = collections
347
439
  // Nullable slugs may cause one route to override the other
@@ -1,5 +1,8 @@
1
1
  """
2
- Product collection type. Possible values are `Department`, `Category`, `Brand`, `Cluster`, `SubCategory` or `Collection`.
2
+ Product collection type. Possible values are `Department`, `Category`, `Brand` or `Collection`.
3
+
4
+ `SubCategory` and `Cluster` are still declared for backward compatibility but are
5
+ deprecated and never returned.
3
6
  """
4
7
  enum StoreCollectionType {
5
8
  """
@@ -12,16 +15,26 @@ enum StoreCollectionType {
12
15
  Category
13
16
  """
14
17
  Third level of product categorization.
18
+
19
+ Deprecated: never returned — third-level categories resolve as `Category`.
15
20
  """
16
21
  SubCategory
22
+ @deprecated(
23
+ reason: "Never returned since the by-linkid migration: the category response only exposes `fatherCategoryId`, which distinguishes root from non-root but not tree depth, so third-level categories resolve as `Category`. Scheduled for removal in the next major."
24
+ )
17
25
  """
18
26
  Product brand.
19
27
  """
20
28
  Brand
21
29
  """
22
30
  Product cluster.
31
+
32
+ Deprecated: never returned — clusters resolve as `Collection`.
23
33
  """
24
34
  Cluster
35
+ @deprecated(
36
+ reason: "Never returned since the by-linkid migration: clusters and curated collections are both served by `collection/by-linkid`, whose response carries no discriminator between them, so both resolve as `Collection`. Scheduled for removal in the next major."
37
+ )
25
38
  """
26
39
  Product collection.
27
40
  """
@@ -80,4 +93,23 @@ type StoreCollection {
80
93
  Collection type.
81
94
  """
82
95
  type: StoreCollectionType!
96
+ """
97
+ Localized versions of this collection for all available locales.
98
+ Only populated when localization is enabled.
99
+ """
100
+ otherLocales: [StoreCollectionLocale!]
101
+ }
102
+
103
+ """
104
+ Localized collection data for a specific locale.
105
+ """
106
+ type StoreCollectionLocale {
107
+ """
108
+ Locale code (e.g. "pt-BR", "it-IT").
109
+ """
110
+ locale: String!
111
+ """
112
+ Localized collection slug (e.g. "vestuario/camisetas").
113
+ """
114
+ slug: String!
83
115
  }
@@ -94,6 +94,25 @@ type StoreProduct {
94
94
  Delivery Promise product's badge.
95
95
  """
96
96
  deliveryPromiseBadges: [DeliveryPromiseBadge]
97
+ """
98
+ Localized versions of this product for all available locales.
99
+ Only populated when localization is enabled.
100
+ """
101
+ otherLocales: [StoreProductLocale!]
102
+ }
103
+
104
+ """
105
+ Localized product data for a specific locale.
106
+ """
107
+ type StoreProductLocale {
108
+ """
109
+ Locale code (e.g. "pt-BR", "it-IT").
110
+ """
111
+ locale: String!
112
+ """
113
+ Localized product slug including the SKU ID suffix (e.g. "adidas-polo-uomo-65").
114
+ """
115
+ slug: String!
97
116
  }
98
117
 
99
118
  type SkuSpecification {
@@ -0,0 +1,45 @@
1
+ import type { GraphqlContext } from '..'
2
+
3
+ export interface LocalizationConfig {
4
+ enabled?: boolean
5
+ defaultLocale?: string
6
+ locales?: Record<string, unknown>
7
+ }
8
+
9
+ export const getLocalizationConfig = (
10
+ ctx: GraphqlContext
11
+ ): LocalizationConfig =>
12
+ (ctx.discoveryConfig as { localization?: LocalizationConfig } | undefined)
13
+ ?.localization ?? {}
14
+
15
+ export const isLocalizationEnabled = (ctx: GraphqlContext): boolean =>
16
+ getLocalizationConfig(ctx).enabled === true
17
+
18
+ export const getConfiguredLocales = (ctx: GraphqlContext): string[] =>
19
+ Object.keys(getLocalizationConfig(ctx).locales ?? {})
20
+
21
+ export const getDefaultLocale = (ctx: GraphqlContext): string | undefined =>
22
+ getLocalizationConfig(ctx).defaultLocale
23
+
24
+ /**
25
+ * Whether `locale` is one of the store's configured locales. Used to validate
26
+ * client-supplied locale input before propagating it to downstream platform
27
+ * APIs. Returns false when localization is disabled (no configured locales).
28
+ */
29
+ export const isConfiguredLocale = (
30
+ ctx: GraphqlContext,
31
+ locale: string
32
+ ): boolean => getConfiguredLocales(ctx).includes(locale)
33
+
34
+ /**
35
+ * Locale to forward to Catalog by-linkid endpoints via the Accept-Language
36
+ * header so they return localized name/title/description. Returns the active
37
+ * locale only when localization is enabled; otherwise `undefined`, so
38
+ * non-localized stores send no header and falls back to the store's default
39
+ * registered language (legacy behavior).
40
+ *
41
+ * Read at request time (not client construction) because `ctx.storage.locale`
42
+ * is mutated later by the collection resolver.
43
+ */
44
+ export const getCatalogLocale = (ctx: GraphqlContext): string | undefined =>
45
+ isLocalizationEnabled(ctx) ? ctx.storage.locale || undefined : undefined