@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.
- package/dist/cjs/index.js +95 -44
- package/dist/cjs/index.js.map +1 -1
- package/dist/es/index.mjs +1088 -837
- package/dist/es/index.mjs.map +1 -1
- package/dist/src/__generated__/schema.d.ts +44 -3
- package/dist/src/__generated__/schema.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/catalog/index.d.ts +34 -0
- package/dist/src/platforms/vtex/clients/catalog/index.d.ts.map +1 -0
- package/dist/src/platforms/vtex/clients/commerce/index.d.ts +6 -0
- package/dist/src/platforms/vtex/clients/commerce/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/commerce/types/ByLinkId.d.ts +45 -0
- package/dist/src/platforms/vtex/clients/commerce/types/ByLinkId.d.ts.map +1 -0
- package/dist/src/platforms/vtex/clients/index.d.ts +8 -0
- package/dist/src/platforms/vtex/clients/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/clients/search/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/index.d.ts +12 -13
- package/dist/src/platforms/vtex/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/loaders/collection.d.ts +32 -3
- package/dist/src/platforms/vtex/loaders/collection.d.ts.map +1 -1
- package/dist/src/platforms/vtex/loaders/index.d.ts +2 -2
- package/dist/src/platforms/vtex/loaders/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/collection.d.ts +20 -2
- package/dist/src/platforms/vtex/resolvers/collection.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/getOrderEntryOperation.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/index.d.ts +3 -13
- package/dist/src/platforms/vtex/resolvers/index.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/product.d.ts +13 -1
- package/dist/src/platforms/vtex/resolvers/product.d.ts.map +1 -1
- package/dist/src/platforms/vtex/resolvers/query.d.ts +8 -17
- package/dist/src/platforms/vtex/resolvers/query.d.ts.map +1 -1
- package/dist/src/platforms/vtex/utils/localization.d.ts +28 -0
- package/dist/src/platforms/vtex/utils/localization.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__generated__/schema.ts +46 -3
- package/src/platforms/vtex/clients/catalog/index.ts +52 -0
- package/src/platforms/vtex/clients/commerce/index.ts +71 -0
- package/src/platforms/vtex/clients/commerce/types/ByLinkId.ts +46 -0
- package/src/platforms/vtex/clients/index.ts +3 -0
- package/src/platforms/vtex/clients/search/index.ts +16 -4
- package/src/platforms/vtex/index.ts +17 -2
- package/src/platforms/vtex/loaders/collection.ts +99 -25
- package/src/platforms/vtex/loaders/index.ts +2 -1
- package/src/platforms/vtex/resolvers/collection.ts +173 -66
- package/src/platforms/vtex/resolvers/product.ts +160 -11
- package/src/platforms/vtex/resolvers/query.ts +148 -56
- package/src/platforms/vtex/typeDefs/collection.graphql +33 -1
- package/src/platforms/vtex/typeDefs/product.graphql +19 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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:
|
|
21
|
-
if (
|
|
22
|
-
|
|
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 (
|
|
26
|
-
return
|
|
49
|
+
if (isBrand(root)) {
|
|
50
|
+
return root.linkId
|
|
27
51
|
}
|
|
28
52
|
|
|
29
|
-
|
|
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<
|
|
57
|
+
export const StoreCollection: Record<string, GraphqlResolver<ByLinkIdRoot>> = {
|
|
33
58
|
id: ({ id }) => id.toString(),
|
|
34
59
|
slug: (root) => slugifyRoot(root),
|
|
35
|
-
seo: (root) =>
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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(
|
|
85
|
-
const slugs = segments.map((
|
|
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
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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:
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
}
|