@faststore/api 4.5.0-dev.2 → 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
@@ -2,71 +2,117 @@ import type { GraphqlResolver } from '..'
2
2
  import type { Brand } from '../clients/commerce/types/Brand'
3
3
  import type { CategoryTree } from '../clients/commerce/types/CategoryTree'
4
4
  import type { CollectionPageType } from '../clients/commerce/types/Portal'
5
- import { isCollectionPageType } from '../loaders/collection'
5
+ import {
6
+ isBrand,
7
+ isCategory,
8
+ isCollection,
9
+ type ByLinkIdBrandRoot,
10
+ type ByLinkIdCategoryRoot,
11
+ type ByLinkIdCollectionRoot,
12
+ } from '../loaders/collection'
13
+ import { getCatalogLocale, getLocalizationConfig } from '../utils/localization'
6
14
  import { slugify } from '../utils/slugify'
7
15
 
8
- export type Root =
16
+ type ByLinkIdRoot =
17
+ | ByLinkIdCategoryRoot
18
+ | ByLinkIdBrandRoot
19
+ | ByLinkIdCollectionRoot
20
+
21
+ /**
22
+ * @deprecated Legacy pagetype-based shape. It is no longer produced at runtime
23
+ * since the by-linkid migration and only remains in the public `Root` union to
24
+ * avoid a breaking change for stores that still reference `StoreCollectionRoot`.
25
+ *
26
+ * TODO: remove in the next major of `@faststore/api` (drop from the `Root`
27
+ * union below) — this is a breaking change and must ship with a BREAKING CHANGE note.
28
+ */
29
+ export type LegacyStoreCollectionRoot =
9
30
  | Brand
10
31
  | (CategoryTree & { level: number })
11
32
  | CollectionPageType
12
33
 
13
- const isBrand = (x: any): x is Brand | CollectionPageType =>
14
- x.type === 'brand' ||
15
- (isCollectionPageType(x) && x.pageType.toLowerCase() === 'brand')
16
-
17
- const isCollection = (x: Root): x is CollectionPageType =>
18
- isCollectionPageType(x) && x.pageType.toLowerCase() === 'collection'
34
+ /**
35
+ * Public `StoreCollectionRoot` type (re-exported from the package entrypoint).
36
+ * Kept as a backward-compatible superset: the legacy members are retained
37
+ * (see {@link LegacyStoreCollectionRoot}) so existing consumers keep compiling,
38
+ * while `ByLinkIdRoot` reflects the real runtime shape.
39
+ */
40
+ export type Root = ByLinkIdRoot | LegacyStoreCollectionRoot
19
41
 
20
- const slugifyRoot = (root: Root) => {
21
- if (isBrand(root) || isCollection(root)) {
22
- return slugify(root.name)
42
+ const slugifyRoot = (root: ByLinkIdRoot): string => {
43
+ if (isCategory(root)) {
44
+ // root.slug is the full accumulated input slug (e.g. "vestuario/camisetas"),
45
+ // injected by the loader — no URL parsing needed.
46
+ return root.slug
23
47
  }
24
48
 
25
- if (isCollectionPageType(root)) {
26
- return new URL(`https://${root.url}`).pathname.slice(1).toLowerCase()
49
+ if (isBrand(root)) {
50
+ return root.linkId
27
51
  }
28
52
 
29
- return new URL(root.url).pathname.slice(1).toLowerCase()
53
+ // collection — linkId may be null for clusters not yet registered in multilanguage
54
+ return root.linkId ?? slugify(root.name)
30
55
  }
31
56
 
32
- export const StoreCollection: Record<string, GraphqlResolver<Root>> = {
57
+ export const StoreCollection: Record<string, GraphqlResolver<ByLinkIdRoot>> = {
33
58
  id: ({ id }) => id.toString(),
34
59
  slug: (root) => slugifyRoot(root),
35
- seo: (root) =>
36
- isBrand(root) || isCollectionPageType(root)
37
- ? {
38
- title: root.title ?? root.name,
39
- description: root.metaTagDescription,
40
- }
41
- : {
42
- title: root.Title,
43
- description: root.MetaTagDescription,
44
- },
45
- type: (root) =>
46
- isBrand(root)
47
- ? 'Brand'
48
- : isCollectionPageType(root)
49
- ? root.pageType
50
- : root.level === 0
51
- ? 'Department'
52
- : 'Category',
53
- meta: (root) => {
60
+ seo: (root) => ({
61
+ title: root.title ?? root.name,
62
+ // pagetype.metaTagDescription and catalog `description` share the same
63
+ // source (confirmed with Catalog). Prefer metaTagDescription when present
64
+ // for forward-compat; fall back to description for by-linkid parity.
65
+ description: root.metaTagDescription ?? root.description,
66
+ }),
67
+ type: (root) => {
68
+ if (isBrand(root)) return 'Brand'
69
+ // Clusters and curated collections share the collection/by-linkid endpoint,
70
+ // whose response has no discriminator between them, so both report as
71
+ // 'Collection'. The enum still declares 'Cluster' for backward compat.
72
+ if (isCollection(root)) return 'Collection'
73
+ // Department = root category (no parent); Category = everything else.
74
+ // SubCategory distinction (3rd level+) requires recursive parent lookup — deferred.
75
+ return root.fatherCategoryId === null ? 'Department' : 'Category'
76
+ },
77
+ meta: async (root, _, ctx) => {
54
78
  const slug = slugifyRoot(root)
55
79
 
56
- return isBrand(root)
57
- ? {
58
- selectedFacets: [{ key: 'brand', value: slug }],
59
- }
60
- : isCollection(root)
61
- ? {
62
- selectedFacets: [{ key: 'productclusterids', value: root.id }],
63
- }
64
- : {
65
- selectedFacets: slug.split('/').map((segment, index) => ({
66
- key: `category-${index + 1}`,
67
- value: segment,
68
- })),
69
- }
80
+ if (isBrand(root)) {
81
+ return { selectedFacets: [{ key: 'brand', value: slug }] }
82
+ }
83
+
84
+ if (isCollection(root)) {
85
+ return { selectedFacets: [{ key: 'productclusterids', value: root.id }] }
86
+ }
87
+
88
+ // For categories, IS expects the canonical (default-locale) slug in selectedFacets
89
+ // regardless of which locale the current request uses. The by-linkid API echoes the
90
+ // queried slug in `linkId`, so we resolve canonical via availableLinkIds[defaultLocale].
91
+ const { defaultLocale } = getLocalizationConfig(ctx)
92
+
93
+ const segments = slug.split('/').filter(Boolean)
94
+ const segmentSlugs = segments.map((_, i) =>
95
+ segments.slice(0, i + 1).join('/')
96
+ )
97
+
98
+ const {
99
+ loaders: { collectionLoader },
100
+ } = ctx
101
+
102
+ const entities = await Promise.all(
103
+ segmentSlugs.map((s) =>
104
+ collectionLoader.load({ slug: s, locale: getCatalogLocale(ctx) })
105
+ )
106
+ )
107
+
108
+ return {
109
+ selectedFacets: entities.map((entity, index) => ({
110
+ key: `category-${index + 1}`,
111
+ value:
112
+ (defaultLocale && entity.availableLinkIds?.[defaultLocale]) ||
113
+ entity.linkId,
114
+ })),
115
+ }
70
116
  },
71
117
  breadcrumbList: async (root, _, ctx) => {
72
118
  const {
@@ -76,36 +122,97 @@ export const StoreCollection: Record<string, GraphqlResolver<Root>> = {
76
122
  const slug = slugifyRoot(root)
77
123
 
78
124
  /**
79
- * Split slug into segments so we fetch all data for
80
- * the breadcrumb. For instance, if we get `/foo/bar`
81
- * we need all metadata for both `/foo` and `/bar` and
82
- * thus we need to fetch pageType for `/foo` and `/bar`
125
+ * Split slug into segments so each breadcrumb level gets its own
126
+ * by-linkid result. For "vestuario/camisetas" this produces two loader
127
+ * calls: one for "vestuario" and one for "vestuario/camisetas".
83
128
  */
84
- const segments = slug.split('/').filter((segment) => Boolean(segment))
85
- const slugs = segments.map((__, index) =>
129
+ const segments = slug.split('/').filter(Boolean)
130
+ const slugs = segments.map((_, index) =>
86
131
  segments.slice(0, index + 1).join('/')
87
132
  )
88
133
 
89
- const collections: (CollectionPageType & {
90
- slug: string
91
- })[] = await Promise.all(
92
- slugs.map(async (s) => {
93
- const collection = await collectionLoader.load(s)
94
- return { slug: s, ...collection }
95
- })
134
+ const collections = await Promise.all(
135
+ slugs.map((s) =>
136
+ collectionLoader.load({ slug: s, locale: getCatalogLocale(ctx) })
137
+ )
96
138
  )
97
139
 
98
140
  return {
99
141
  itemListElement: collections.map((collection, index) => ({
100
- item: isCollection(collection)
101
- ? `/${collection.slug}`
102
- : new URL(
103
- `https://${(collection as CollectionPageType).url}`
104
- ).pathname.toLowerCase(),
142
+ item: `/${slugifyRoot(collection)}`,
105
143
  name: collection.name,
106
144
  position: index + 1,
107
145
  })),
108
146
  numberOfItems: collections.length,
109
147
  }
110
148
  },
149
+
150
+ otherLocales: async (root, _, ctx) => {
151
+ const localizationConfig = getLocalizationConfig(ctx)
152
+
153
+ if (!localizationConfig.enabled) return null
154
+
155
+ const configuredLocales = Object.keys(localizationConfig.locales ?? {})
156
+
157
+ if (configuredLocales.length === 0) return null
158
+
159
+ const currentLocale = ctx.storage.locale
160
+ const slug = slugifyRoot(root)
161
+ const segments = slug.split('/').filter(Boolean)
162
+
163
+ if (segments.length === 0) return null
164
+
165
+ const {
166
+ loaders: { collectionLoader },
167
+ } = ctx
168
+
169
+ // Build per-level slug paths: ["vestuario", "vestuario/camisetas"].
170
+ // The collectionLoader DataLoader cache means any segment already fetched
171
+ // by breadcrumbList or meta costs nothing here.
172
+ const segmentSlugs = segments.map((_, i) =>
173
+ segments.slice(0, i + 1).join('/')
174
+ )
175
+
176
+ let entities: ByLinkIdRoot[]
177
+
178
+ try {
179
+ entities = await Promise.all(
180
+ segmentSlugs.map((s) =>
181
+ collectionLoader.load({ slug: s, locale: getCatalogLocale(ctx) })
182
+ )
183
+ )
184
+ } catch (err) {
185
+ console.warn('[otherLocales] failed to load collection entities:', err)
186
+
187
+ return null
188
+ }
189
+
190
+ return configuredLocales
191
+ .map((configuredLocale) => {
192
+ if (configuredLocale === currentLocale) {
193
+ // The input slug is already the localized path for the current locale.
194
+ return { locale: configuredLocale, slug }
195
+ }
196
+
197
+ // Build the full path by joining each segment's localized linkId from
198
+ // availableLinkIds. For the default locale, the canonical slug is also
199
+ // present in availableLinkIds.
200
+ // If any segment is missing an entry for this locale, omit the whole URL
201
+ // to keep the hreflang cluster symmetric.
202
+ const parts: string[] = []
203
+
204
+ for (const entity of entities) {
205
+ const linkId = entity.availableLinkIds?.[configuredLocale]
206
+
207
+ if (!linkId) return null
208
+
209
+ parts.push(linkId)
210
+ }
211
+
212
+ return parts.length > 0
213
+ ? { locale: configuredLocale, slug: parts.join('/') }
214
+ : null
215
+ })
216
+ .filter((e): e is { locale: string; slug: string } => e !== null)
217
+ },
111
218
  }
@@ -1,9 +1,15 @@
1
- import type { GraphqlResolver } from '..'
1
+ import type { GraphqlContext, GraphqlResolver } from '..'
2
2
  import type { StoreImage, StoreProductImageArgs } from '../../..'
3
+ import type { LocalizedProductEntry } from '../clients/catalog'
3
4
  import type { Attachment } from '../clients/commerce/types/OrderForm'
4
5
  import { canonicalFromProduct } from '../utils/canonical'
5
6
  import type { EnhancedCommercialOffer } from '../utils/enhanceCommercialOffer'
6
7
  import { enhanceCommercialOffer } from '../utils/enhanceCommercialOffer'
8
+ import {
9
+ getConfiguredLocales,
10
+ getDefaultLocale,
11
+ isLocalizationEnabled,
12
+ } from '../utils/localization'
7
13
  import { bestOfferFirst } from '../utils/productStock'
8
14
  import {
9
15
  attachmentToPropertyValue,
@@ -36,6 +42,50 @@ function removeTrailingSlashes(path: string) {
36
42
  return path.replace(/^\/+|\/+$/g, '')
37
43
  }
38
44
 
45
+ /**
46
+ * Returns a cached-or-fetched localized product entry from the Catalog Dataplane.
47
+ * The promise is stored in `ctx.storage.productTranslationsCache` so it is shared
48
+ * across the `breadcrumbList`, `otherLocales`, and slug-validation resolvers within
49
+ * the same request.
50
+ *
51
+ * The in-flight promise (not just the resolved value) is cached so concurrent
52
+ * sibling resolvers dedupe to a single Catalog Dataplane request instead of each
53
+ * missing the cache and issuing a duplicate fetch.
54
+ */
55
+ export async function getLocalizedProductEntry(
56
+ ctx: GraphqlContext,
57
+ productId: string,
58
+ locale: string
59
+ ): Promise<LocalizedProductEntry | null> {
60
+ const cacheKey = `${productId}:${locale}`
61
+ ctx.storage.productTranslationsCache ??= new Map()
62
+ const cache = ctx.storage.productTranslationsCache
63
+
64
+ const cached = cache.get(cacheKey)
65
+ if (cached) return cached
66
+
67
+ const entry = (async (): Promise<LocalizedProductEntry | null> => {
68
+ try {
69
+ const result = await ctx.clients.catalog.getLocalizedProduct(
70
+ productId,
71
+ locale
72
+ )
73
+
74
+ return {
75
+ linkId: result.linkId,
76
+ categories: result.categories ?? [],
77
+ availableLinkIds: result.availableLinkIds ?? {},
78
+ }
79
+ } catch {
80
+ return null
81
+ }
82
+ })()
83
+
84
+ cache.set(cacheKey, entry)
85
+
86
+ return entry
87
+ }
88
+
39
89
  /**
40
90
  * Finds the index of the main category tree that matches the given category ID.
41
91
  * This avoids including similar categories in the breadcrumb list.
@@ -77,20 +127,74 @@ export const StoreProduct: Record<string, GraphqlResolver<Root>> & {
77
127
  }),
78
128
  brand: ({ isVariantOf: { brand } }) => ({ name: brand }),
79
129
  unitMultiplier: ({ unitMultiplier }) => unitMultiplier,
80
- breadcrumbList: ({
81
- isVariantOf: {
82
- categories,
83
- productName,
84
- linkText,
85
- categoryId,
86
- categoriesIds,
87
- },
88
- itemId,
89
- }) => {
130
+ breadcrumbList: async (root, _args, ctx) => {
131
+ const {
132
+ isVariantOf: {
133
+ categories,
134
+ productName,
135
+ linkText,
136
+ categoryId,
137
+ categoriesIds,
138
+ productId,
139
+ },
140
+ itemId,
141
+ } = root
142
+
90
143
  const mainTreeIndex = findMainTreeIndex(categoriesIds, categoryId)
91
144
  const mainTree = categories[mainTreeIndex]
92
145
  const splittedCategories = removeTrailingSlashes(mainTree).split('/')
93
146
 
147
+ const locale = ctx.storage.locale
148
+
149
+ if (isLocalizationEnabled(ctx) && locale) {
150
+ const entry = await getLocalizedProductEntry(ctx, productId, locale)
151
+
152
+ if (entry) {
153
+ // Extract the category IDs that belong to the main tree (same tree chosen from IS above).
154
+ // A product can be registered in multiple trees; Catalog Dataplane returns all of them
155
+ // in categories[], so we filter to only the ones matching this tree's IDs.
156
+ const mainTreeIds = new Set(
157
+ removeTrailingSlashes(categoriesIds[mainTreeIndex])
158
+ .split('/')
159
+ .filter(Boolean)
160
+ )
161
+
162
+ const localizedCategories = entry.categories
163
+ .filter((category) => mainTreeIds.has(category.id.toString()))
164
+ .sort(
165
+ (a, b) =>
166
+ a.fullPath.split('/').length - b.fullPath.split('/').length
167
+ )
168
+
169
+ // Length guard: if Catalog Dataplane returns fewer categories than IS expects
170
+ // (e.g. data inconsistency or empty categories), fall through to the IS fallback.
171
+ const hasAllBreadcrumbLevels =
172
+ localizedCategories.length === splittedCategories.length
173
+ if (hasAllBreadcrumbLevels) {
174
+ return {
175
+ itemListElement: [
176
+ // Category items: both name and slug come from Catalog Dataplane, ensuring
177
+ // they are always consistent with each other for the requested locale.
178
+ ...localizedCategories.map((category, index) => ({
179
+ name: category.name,
180
+ item: `/${category.fullPathUriName}/`,
181
+ position: index + 1,
182
+ })),
183
+ {
184
+ name: productName,
185
+ item: getPath(entry.linkId, itemId),
186
+ position: splittedCategories.length + 1,
187
+ },
188
+ ],
189
+ numberOfItems: splittedCategories.length,
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // Fallback: localization disabled, Catalog Dataplane unavailable, or category count mismatch.
196
+ // Builds paths by applying slugify() to the IS category names, which mirrors the behaviour
197
+ // of the VTEX Rewriter for default-locale slugs.
94
198
  return {
95
199
  itemListElement: [
96
200
  ...splittedCategories.map((name, index) => {
@@ -198,4 +302,49 @@ export const StoreProduct: Record<string, GraphqlResolver<Root>> & {
198
302
  advertisement: ({ isVariantOf: { advertisement } }) => advertisement,
199
303
  deliveryPromiseBadges: ({ isVariantOf: { deliveryPromisesBadges } }) =>
200
304
  deliveryPromisesBadges,
305
+ otherLocales: async (root, _args, ctx) => {
306
+ if (!isLocalizationEnabled(ctx)) return null
307
+
308
+ const configuredLocales = getConfiguredLocales(ctx)
309
+
310
+ if (configuredLocales.length === 0) return null
311
+
312
+ const productId = root.isVariantOf.productId
313
+ const itemId = root.itemId
314
+ const locale = ctx.storage.locale
315
+ const defaultLocale = getDefaultLocale(ctx)
316
+
317
+ // availableLinkIds returns localized slug for every locale,
318
+ // we fetch for the current locale (reusing the request-scoped cache shared with the slug and
319
+ // breadcrumb resolvers) and read the full map from the response.
320
+ const entry = await getLocalizedProductEntry(ctx, productId, locale)
321
+
322
+ if (!entry?.availableLinkIds) return null
323
+
324
+ const { availableLinkIds } = entry
325
+ const { linkText } = root.isVariantOf
326
+
327
+ return configuredLocales
328
+ .map((configuredLocale) => {
329
+ // The default locale always uses the canonical IS linkText: it is always
330
+ // present and matches the Query.product `slug.startsWith(linkText)` fast
331
+ // path, so the fallback URL resolves cleanly even when the catalog has no
332
+ // default-locale entry in availableLinkIds.
333
+ if (configuredLocale === defaultLocale) {
334
+ return { locale: configuredLocale, slug: getSlug(linkText, itemId) }
335
+ }
336
+
337
+ // Non-default locales only appear when they have a registered localized slug
338
+ // in availableLinkIds. Untranslated locales are omitted so they are never
339
+ // advertised as hreflang alternates — this keeps the hreflang cluster
340
+ // symmetric across all locale variants of the product (every variant emits
341
+ // the same set: default + translated locales). The LocalizationSelector
342
+ // falls back to the default slug under the target prefix for omitted locales.
343
+ const linkId = availableLinkIds[configuredLocale]
344
+ return linkId
345
+ ? { locale: configuredLocale, slug: getSlug(linkId, itemId) }
346
+ : null
347
+ })
348
+ .filter((e): e is { locale: string; slug: string } => e !== null)
349
+ },
201
350
  }