@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
@@ -1347,6 +1347,11 @@ export type StoreCollection = {
1347
1347
  id: Scalars['ID']['output'];
1348
1348
  /** Collection meta information. Used for search. */
1349
1349
  meta: StoreCollectionMeta;
1350
+ /**
1351
+ * Localized versions of this collection for all available locales.
1352
+ * Only populated when localization is enabled.
1353
+ */
1354
+ otherLocales?: Maybe<Array<StoreCollectionLocale>>;
1350
1355
  /** Meta tag data. */
1351
1356
  seo: StoreSeo;
1352
1357
  /** Corresponding collection URL slug, with which to retrieve this entity. */
@@ -1382,6 +1387,15 @@ export type StoreCollectionFacet = {
1382
1387
  value: Scalars['String']['output'];
1383
1388
  };
1384
1389
 
1390
+ /** Localized collection data for a specific locale. */
1391
+ export type StoreCollectionLocale = {
1392
+ __typename?: 'StoreCollectionLocale';
1393
+ /** Locale code (e.g. "pt-BR", "it-IT"). */
1394
+ locale: Scalars['String']['output'];
1395
+ /** Localized collection slug (e.g. "vestuario/camisetas"). */
1396
+ slug: Scalars['String']['output'];
1397
+ };
1398
+
1385
1399
  /** Collection meta information. Used for search. */
1386
1400
  export type StoreCollectionMeta = {
1387
1401
  __typename?: 'StoreCollectionMeta';
@@ -1389,19 +1403,34 @@ export type StoreCollectionMeta = {
1389
1403
  selectedFacets: Array<StoreCollectionFacet>;
1390
1404
  };
1391
1405
 
1392
- /** Product collection type. Possible values are `Department`, `Category`, `Brand`, `Cluster`, `SubCategory` or `Collection`. */
1406
+ /**
1407
+ * Product collection type. Possible values are `Department`, `Category`, `Brand` or `Collection`.
1408
+ *
1409
+ * `SubCategory` and `Cluster` are still declared for backward compatibility but are
1410
+ * deprecated and never returned.
1411
+ */
1393
1412
  export const enum StoreCollectionType {
1394
1413
  /** Product brand. */
1395
1414
  Brand = 'Brand',
1396
1415
  /** Second level of product categorization. */
1397
1416
  Category = 'Category',
1398
- /** Product cluster. */
1417
+ /**
1418
+ * Product cluster.
1419
+ *
1420
+ * Deprecated: never returned — clusters resolve as `Collection`.
1421
+ * @deprecated 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.
1422
+ */
1399
1423
  Cluster = 'Cluster',
1400
1424
  /** Product collection. */
1401
1425
  Collection = 'Collection',
1402
1426
  /** First level of product categorization. */
1403
1427
  Department = 'Department',
1404
- /** Third level of product categorization. */
1428
+ /**
1429
+ * Third level of product categorization.
1430
+ *
1431
+ * Deprecated: never returned — third-level categories resolve as `Category`.
1432
+ * @deprecated 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.
1433
+ */
1405
1434
  SubCategory = 'SubCategory'
1406
1435
  };
1407
1436
 
@@ -1688,6 +1717,11 @@ export type StoreProduct = {
1688
1717
  name: Scalars['String']['output'];
1689
1718
  /** Aggregate offer information. */
1690
1719
  offers: StoreAggregateOffer;
1720
+ /**
1721
+ * Localized versions of this product for all available locales.
1722
+ * Only populated when localization is enabled.
1723
+ */
1724
+ otherLocales?: Maybe<Array<StoreProductLocale>>;
1691
1725
  /** Product ID, such as [ISBN](https://www.isbn-international.org/content/what-isbn) or similar global IDs. */
1692
1726
  productID: Scalars['String']['output'];
1693
1727
  /** The product's release date. Formatted using https://en.wikipedia.org/wiki/ISO_8601 */
@@ -1752,6 +1786,15 @@ export type StoreProductGroup = {
1752
1786
  skuVariants?: Maybe<SkuVariants>;
1753
1787
  };
1754
1788
 
1789
+ /** Localized product data for a specific locale. */
1790
+ export type StoreProductLocale = {
1791
+ __typename?: 'StoreProductLocale';
1792
+ /** Locale code (e.g. "pt-BR", "it-IT"). */
1793
+ locale: Scalars['String']['output'];
1794
+ /** Localized product slug including the SKU ID suffix (e.g. "adidas-polo-uomo-65"). */
1795
+ slug: Scalars['String']['output'];
1796
+ };
1797
+
1755
1798
  /** Properties that can be associated with products and products groups. */
1756
1799
  export type StorePropertyValue = {
1757
1800
  __typename?: 'StorePropertyValue';
@@ -0,0 +1,52 @@
1
+ import { fetchAPI } from '../fetch'
2
+
3
+ export interface LocalizedCategoryEntry {
4
+ id: number
5
+ name: string
6
+ /** Slash-separated IDs from root to this node, e.g. "9281/9285". Used to determine depth. */
7
+ fullPath: string
8
+ /** Slash-separated localized slugs from root to this node, e.g. "apparel/t-shirts". */
9
+ fullPathUriName: string
10
+ }
11
+
12
+ export interface LocalizedProductEntry {
13
+ linkId: string
14
+ /** All categories the product belongs to across all trees, as returned by Catalog Dataplane. */
15
+ categories: LocalizedCategoryEntry[]
16
+ /** Localized linkIds keyed by locale, covering all available locales in a single response. */
17
+ availableLinkIds: Record<string, string>
18
+ }
19
+
20
+ export interface LocalizedProductResponse {
21
+ id: number
22
+ linkId: string
23
+ name: string
24
+ /** Leaf category (deepest level) the product is registered under. */
25
+ category: LocalizedCategoryEntry | null
26
+ /** Full ancestry chain for every category tree the product belongs to. */
27
+ categories: LocalizedCategoryEntry[]
28
+ /** Localized linkIds keyed by locale, covering all available locales in a single response. */
29
+ availableLinkIds: Record<string, string>
30
+ }
31
+
32
+ /**
33
+ * Client for the VTEX Catalog Dataplane API.
34
+ * Uses Accept-Language header to return locale-specific product data.
35
+ */
36
+ export const CatalogDataplane = ({ account, environment }: Options) => {
37
+ const base = `https://${account}.${environment}.com.br`
38
+
39
+ return {
40
+ getLocalizedProduct: (
41
+ productId: string,
42
+ locale: string
43
+ ): Promise<LocalizedProductResponse> =>
44
+ fetchAPI(`${base}/api/catalog-dataplane/product/${productId}`, {
45
+ method: 'GET',
46
+ headers: {
47
+ 'Accept-Language': locale,
48
+ 'Content-Type': 'application/json',
49
+ },
50
+ }),
51
+ }
52
+ }
@@ -14,6 +14,7 @@ import {
14
14
  type UserOrderCancel,
15
15
  type UserOrderListResult,
16
16
  } from '../../../..'
17
+ import { isNotFoundError } from '../../../errors'
17
18
  import type { GraphqlContext } from '../../index'
18
19
  import { getWithAppKeyAndToken } from '../../utils/auth'
19
20
  import type { Channel } from '../../utils/channel'
@@ -26,6 +27,11 @@ import {
26
27
  import type { ContractResponse } from './Contract'
27
28
  import type { Address, AddressInput } from './types/Address'
28
29
  import type { Brand } from './types/Brand'
30
+ import type {
31
+ ByLinkIdBrandResponse,
32
+ ByLinkIdCategoryResponse,
33
+ ByLinkIdCollectionResponse,
34
+ } from './types/ByLinkId'
29
35
  import type { CategoryTree } from './types/CategoryTree'
30
36
  import type { MasterDataResponse } from './types/Newsletter'
31
37
  import type {
@@ -74,6 +80,24 @@ const BASE_INIT = {
74
80
  },
75
81
  }
76
82
 
83
+ /**
84
+ * Encode a by-linkid path for the category endpoint. Category link paths can be
85
+ * multi-segment (e.g. "computer---software/eletronicos"); the Catalog endpoint
86
+ * expects the "/" separators to stay literal so it can validate each level,
87
+ * while each segment is individually URL-encoded. Running encodeURIComponent on
88
+ * the whole string would turn "/" into "%2F" and break multi-segment resolution.
89
+ */
90
+ const encodeLinkIdPath = (linkId: string): string =>
91
+ linkId.split('/').map(encodeURIComponent).join('/')
92
+
93
+ /**
94
+ * Build the fetch init that forwards a locale to a by-linkid endpoint. When no
95
+ * locale is provided the endpoint falls back to the store's default registered
96
+ * language (non-localized stores behavior).
97
+ */
98
+ const byLinkIdInit = (locale?: string): RequestInit | undefined =>
99
+ locale ? { headers: { 'Accept-Language': locale } } : undefined
100
+
77
101
  const QUOTE_ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
78
102
  const QUOTE_VALID_STATUSES = new Set([
79
103
  'Draft',
@@ -222,6 +246,53 @@ export const VtexCommerce = (
222
246
  { storeCookies }
223
247
  ),
224
248
  },
249
+ byLinkId: {
250
+ // The by-linkid endpoints resolve a slug to a single catalog entity
251
+ // (or 404 when there is no match). We surface a 404 as `null` so the
252
+ // loader can cascade category → brand → collection.
253
+ category: async (
254
+ linkId: string,
255
+ locale?: string
256
+ ): Promise<ByLinkIdCategoryResponse | null> => {
257
+ try {
258
+ return await fetchAPI(
259
+ `${base}/api/catalog_system/pub/category/by-linkid/${encodeLinkIdPath(linkId)}`,
260
+ byLinkIdInit(locale)
261
+ )
262
+ } catch (error) {
263
+ if (isNotFoundError(error)) return null
264
+ throw error
265
+ }
266
+ },
267
+ brand: async (
268
+ linkId: string,
269
+ locale?: string
270
+ ): Promise<ByLinkIdBrandResponse | null> => {
271
+ try {
272
+ return await fetchAPI(
273
+ `${base}/api/catalog_system/pub/brand/by-linkid/${encodeURIComponent(linkId)}`,
274
+ byLinkIdInit(locale)
275
+ )
276
+ } catch (error) {
277
+ if (isNotFoundError(error)) return null
278
+ throw error
279
+ }
280
+ },
281
+ collection: async (
282
+ linkId: string,
283
+ locale?: string
284
+ ): Promise<ByLinkIdCollectionResponse | null> => {
285
+ try {
286
+ return await fetchAPI(
287
+ `${base}/api/catalog_system/pub/collection/by-linkid/${encodeURIComponent(linkId)}`,
288
+ byLinkIdInit(locale)
289
+ )
290
+ } catch (error) {
291
+ if (isNotFoundError(error)) return null
292
+ throw error
293
+ }
294
+ },
295
+ },
225
296
  products: {
226
297
  crossselling: ({
227
298
  type,
@@ -0,0 +1,46 @@
1
+ export interface ByLinkIdCategoryResponse {
2
+ id: number
3
+ fatherCategoryId: number | null
4
+ name: string
5
+ linkId: string
6
+ title: string | null
7
+ description: string | null
8
+ /**
9
+ * Optional SEO meta description. pagetype.metaTagDescription
10
+ * was backed by the same source as `description`; prefer `description` for
11
+ * parity when this field is absent.
12
+ */
13
+ metaTagDescription: string | null
14
+ /** Localized linkIds keyed by locale. Null when no multilanguage entries are registered. */
15
+ availableLinkIds: Record<string, string> | null
16
+ }
17
+
18
+ export interface ByLinkIdBrandResponse {
19
+ id: number
20
+ name: string
21
+ linkId: string
22
+ title: string | null
23
+ description: string | null
24
+ /**
25
+ * Optional SEO meta description. Catalog confirmed pagetype.metaTagDescription
26
+ * was backed by the same source as `description`; prefer `description` for
27
+ * parity when this field is absent.
28
+ */
29
+ metaTagDescription: string | null
30
+ availableLinkIds: Record<string, string> | null
31
+ }
32
+
33
+ export interface ByLinkIdCollectionResponse {
34
+ id: number
35
+ name: string
36
+ linkId: string | null
37
+ title: string | null
38
+ description: string | null
39
+ /**
40
+ * Optional SEO meta description. pagetype.metaTagDescription
41
+ * was backed by the same source as `description`; prefer `description` for
42
+ * parity when this field is absent.
43
+ */
44
+ metaTagDescription: string | null
45
+ availableLinkIds: Record<string, string> | null
46
+ }
@@ -1,4 +1,5 @@
1
1
  import type { GraphqlContext } from '..'
2
+ import { CatalogDataplane } from './catalog'
2
3
  import { VtexCommerce } from './commerce'
3
4
  import { IntelligentSearch } from './search'
4
5
 
@@ -7,9 +8,11 @@ export type Clients = ReturnType<typeof getClients>
7
8
  export const getClients = (options: Options, ctx: GraphqlContext) => {
8
9
  const search = IntelligentSearch(options, ctx)
9
10
  const commerce = VtexCommerce(options, ctx)
11
+ const catalog = CatalogDataplane(options)
10
12
 
11
13
  return {
12
14
  search,
13
15
  commerce,
16
+ catalog,
14
17
  }
15
18
  }
@@ -3,6 +3,7 @@ import pLimit from 'p-limit'
3
3
  import type { GraphqlContext } from '../../'
4
4
  import { getWithCookie } from '../../utils/cookies'
5
5
  import type { SelectedFacet } from '../../utils/facets'
6
+ import { isLocalizationEnabled } from '../../utils/localization'
6
7
  import {
7
8
  buildIntelligentSearchRequest,
8
9
  parseSegmentCookie,
@@ -80,10 +81,21 @@ function getRegionIdFromContext(ctx: GraphqlContext): string | undefined {
80
81
 
81
82
  function getSegmentLocale(ctx: GraphqlContext): string {
82
83
  const segment = parseSegmentCookie(ctx.headers?.cookie)
83
-
84
- // Prefer ctx.storage.locale (set from trusted selectedFacets) over the
85
- // vtex_segment cookie, which can lag on hard locale-switch navigation.
86
- return ctx.storage.locale || (segment.cultureInfo as string | undefined) || ''
84
+ const cultureInfo = (segment.cultureInfo as string | undefined) || ''
85
+
86
+ // Locale flows from the URL into IS in one chain:
87
+ // router.locale (Next.js)
88
+ // → selectedFacets locale facet (useLocalizedVariables in @faststore/core)
89
+ // → ctx.storage.locale (mutateLocaleContext in query.ts)
90
+ // → IS locale query param (here)
91
+ //
92
+ // For localized stores: prefer storage.locale over the vtex_segment cookie.
93
+ // The cookie can lag after locale navigation, while storage.locale is derived
94
+ // from the trusted selectedFacets locale facet in the same request.
95
+
96
+ return isLocalizationEnabled(ctx)
97
+ ? ctx.storage.locale || cultureInfo
98
+ : cultureInfo || ctx.storage.locale || ''
87
99
  }
88
100
 
89
101
  export const IntelligentSearch = (
@@ -32,10 +32,22 @@ export interface GraphqlContext {
32
32
  flags: FeatureFlags
33
33
  searchArgs?: Omit<SearchArgs, 'type'>
34
34
  cookies: Map<string, Record<string, string>>
35
+ /**
36
+ * Cached in-flight localized product lookups keyed by "productId:locale".
37
+ * Stores the promise (not just the resolved value) so concurrent sibling
38
+ * resolvers (slug validation, otherLocales, breadcrumb) dedupe to a single
39
+ * Catalog Dataplane request within the same request.
40
+ */
41
+ productTranslationsCache?: Map<
42
+ string,
43
+ Promise<import('./clients/catalog').LocalizedProductEntry | null>
44
+ >
35
45
  }
36
46
  headers: Record<string, string>
37
47
  account: string
38
48
  OTEL: Record<string, unknown>
49
+ /** Discovery config passed from @faststore/core, including localization settings. */
50
+ discoveryConfig?: Record<string, unknown>
39
51
  }
40
52
 
41
53
  export const GraphqlVtexContextFactory = async (options: Options) => {
@@ -49,11 +61,14 @@ export const GraphqlVtexContextFactory = async (options: Options) => {
49
61
  locale: options.locale,
50
62
  cookies: new Map<string, Record<string, string>>(),
51
63
  }
52
- ctx.clients = getClients(options, ctx)
53
- ctx.loaders = getLoaders(options, ctx)
54
64
  ctx.account = options.account
55
65
  ctx.OTEL = options.OTEL
56
66
  ctx.discoveryConfig = options.discoveryConfig
67
+ // Build clients/loaders last: they capture `ctx` and read storage,
68
+ // discoveryConfig, etc. at request time, so everything they may depend on
69
+ // must already be assigned.
70
+ ctx.clients = getClients(options, ctx)
71
+ ctx.loaders = getLoaders(options, ctx)
57
72
 
58
73
  return ctx
59
74
  }
@@ -3,49 +3,123 @@ import pLimit from 'p-limit'
3
3
 
4
4
  import { NotFoundError } from '../../errors'
5
5
  import type { Clients } from '../clients'
6
- import type { CollectionPageType } from '../clients/commerce/types/Portal'
6
+ import type {
7
+ ByLinkIdBrandResponse,
8
+ ByLinkIdCategoryResponse,
9
+ ByLinkIdCollectionResponse,
10
+ } from '../clients/commerce/types/ByLinkId'
7
11
 
8
- // Limits concurrent requests to 20 so that they don't timeout
9
12
  const CONCURRENT_REQUESTS_MAX = 20
10
13
 
11
- const collectionPageTypes = new Set([
12
- 'brand',
13
- 'category',
14
- 'department',
15
- 'subcategory',
16
- 'collection',
17
- 'cluster',
18
- ] as const)
14
+ /**
15
+ * Load key for the collection DataLoader. `locale` is captured at the call
16
+ * site (not re-read from mutable `ctx.storage`) so concurrent aliased
17
+ * `collection` fields with different locales cannot share a cache entry or
18
+ * race on Accept-Language.
19
+ */
20
+ export type CollectionLoadKey = {
21
+ slug: string
22
+ /** Catalog Accept-Language; `undefined` when localization is disabled. */
23
+ locale?: string
24
+ }
25
+
26
+ export type ByLinkIdCategoryRoot = ByLinkIdCategoryResponse & {
27
+ entityType: 'category'
28
+ /**
29
+ * Full accumulated input slug injected by the loader (e.g. "vestuario/camisetas").
30
+ * Used by slugifyRoot so that meta.selectedFacets builds the correct facet keys
31
+ * without relying on root.url (which is absent in the by-linkid response).
32
+ */
33
+ slug: string
34
+ }
35
+
36
+ export type ByLinkIdBrandRoot = ByLinkIdBrandResponse & {
37
+ entityType: 'brand'
38
+ }
39
+
40
+ export type ByLinkIdCollectionRoot = ByLinkIdCollectionResponse & {
41
+ entityType: 'collection'
42
+ }
43
+
44
+ export type Root =
45
+ | ByLinkIdCategoryRoot
46
+ | ByLinkIdBrandRoot
47
+ | ByLinkIdCollectionRoot
48
+
49
+ export const isCategory = (root: Root): root is ByLinkIdCategoryRoot =>
50
+ root.entityType === 'category'
19
51
 
20
- export const isCollectionPageType = (x: any): x is CollectionPageType =>
21
- typeof x.pageType === 'string' &&
22
- collectionPageTypes.has(x.pageType.toLowerCase())
52
+ export const isBrand = (root: Root): root is ByLinkIdBrandRoot =>
53
+ root.entityType === 'brand'
54
+
55
+ export const isCollection = (root: Root): root is ByLinkIdCollectionRoot =>
56
+ root.entityType === 'collection'
23
57
 
24
58
  export const getCollectionLoader = (_: Options, clients: Clients) => {
25
59
  const limit = pLimit(CONCURRENT_REQUESTS_MAX)
26
60
 
27
- const loader = async (
28
- slugs: readonly string[]
29
- ): Promise<CollectionPageType[]> => {
30
- return Promise.all(
31
- slugs.map((slug: string) =>
61
+ const loader = async (keys: readonly CollectionLoadKey[]): Promise<Root[]> =>
62
+ Promise.all(
63
+ keys.map((key) =>
32
64
  limit(async () => {
33
- const page = await clients.commerce.catalog.portal.pagetype(slug)
65
+ // Normalize to lowercase for DataLoader cache-key consistency
66
+ // (e.g. "Sporting" and "sporting" are the same). The by-linkid API is
67
+ // case-insensitive by design (it preserves the legacy pagetype behavior).
68
+ // Accents are significant and are preserved by toLowerCase() (e.g. "vestuário").
69
+ const normalizedSlug = key.slug.toLowerCase()
70
+ // Use the locale captured on the load key — do not re-read
71
+ // ctx.storage.locale here (it can change mid-request).
72
+ const locale = key.locale
73
+
74
+ // Step 1: category
75
+ // Pass the full path so the API validates each segment and returns only
76
+ // the unambiguous leaf category. E.g. "vestuario/camisetas" resolves to
77
+ // the "camisetas" that is a child of "vestuario", not any other category
78
+ // that happens to share the linkId "camisetas".
79
+ // The full slug is also injected into the result for meta.selectedFacets
80
+ // and breadcrumb URL construction.
81
+ const category = await clients.commerce.catalog.byLinkId.category(
82
+ normalizedSlug,
83
+ locale
84
+ )
85
+ if (category) {
86
+ return {
87
+ ...category,
88
+ entityType: 'category' as const,
89
+ slug: normalizedSlug,
90
+ }
91
+ }
34
92
 
35
- if (isCollectionPageType(page)) {
36
- return page
93
+ // Step 2: brand (always single-segment)
94
+ const brand = await clients.commerce.catalog.byLinkId.brand(
95
+ normalizedSlug,
96
+ locale
97
+ )
98
+ if (brand) {
99
+ return { ...brand, entityType: 'brand' as const }
100
+ }
101
+
102
+ // Step 3: collection cluster (always single-segment)
103
+ const collection = await clients.commerce.catalog.byLinkId.collection(
104
+ normalizedSlug,
105
+ locale
106
+ )
107
+ if (collection) {
108
+ return { ...collection, entityType: 'collection' as const }
37
109
  }
38
110
 
39
111
  throw new NotFoundError(
40
- `Catalog returned ${page.pageType} for slug: ${slug}. This usually happens when there is more than one category with the same name in the same category tree level.`
112
+ `No catalog entity found for slug: ${key.slug}. Cascade exhausted (category → brand → collection).`
41
113
  )
42
114
  })
43
115
  )
44
116
  )
45
- }
46
117
 
47
- return new DataLoader<string, CollectionPageType>(loader, {
48
- // DataLoader is being used to cache requests, not to batch them
118
+ return new DataLoader<CollectionLoadKey, Root, string>(loader, {
119
+ // DataLoader is used for caching, not batching
49
120
  batch: false,
121
+ // Dedup by lowercased slug + captured locale so casing variants share an
122
+ // entry while same-slug / different-locale loads stay isolated.
123
+ cacheKeyFn: ({ slug, locale }) => `${slug.toLowerCase()}::${locale ?? ''}`,
50
124
  })
51
125
  }
@@ -6,7 +6,8 @@ import { getSkuLoader } from './sku'
6
6
 
7
7
  export type Loaders = ReturnType<typeof getLoaders>
8
8
 
9
- export const getLoaders = (options: Options, { clients }: GraphqlContext) => {
9
+ export const getLoaders = (options: Options, ctx: GraphqlContext) => {
10
+ const { clients } = ctx
10
11
  const skuLoader = getSkuLoader(options, clients)
11
12
  const simulationLoader = getSimulationLoader(options, clients)
12
13
  const collectionLoader = getCollectionLoader(options, clients)