@thorprovider/storefront 0.4.0-mvp.0 → 0.4.0-mvp.2

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 (166) hide show
  1. package/README.md +173 -153
  2. package/VENDOR.md +69 -0
  3. package/assets/openspec/config.yaml +38 -0
  4. package/assets/templates/AGENTS.md +76 -0
  5. package/assets/templates/app/app/[[...slug]]/page.tsx +42 -1
  6. package/assets/templates/app/app/[[...slug]]/renderer.tsx +13 -2
  7. package/assets/templates/app/app/globals.css +5 -8
  8. package/assets/templates/app/app/layout.tsx +1 -5
  9. package/assets/templates/app/app/robots.ts +9 -3
  10. package/assets/templates/app/app/sitemap.ts +64 -5
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +70 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +15 -5
  13. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +71 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +36 -4
  16. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +82 -50
  17. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +29 -14
  19. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +31 -7
  22. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +4 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +56 -26
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +165 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +26 -7
  26. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +2 -1
  27. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  30. package/assets/templates/features/checkout/handlers.ts +6 -0
  31. package/assets/templates/features/customer-auth/state.ts +10 -1
  32. package/assets/templates/views/account/index.tsx +1 -1
  33. package/assets/templates/views/account/registry.tsx +80 -19
  34. package/assets/templates/views/account/schema.ts +1 -0
  35. package/assets/templates/views/cart/content.ts +11 -0
  36. package/assets/templates/views/cart/data.ts +23 -0
  37. package/assets/templates/views/cart/index.tsx +53 -0
  38. package/assets/templates/views/cart/props.ts +12 -0
  39. package/assets/templates/views/cart/registry.tsx +81 -0
  40. package/assets/templates/views/cart/schema.ts +52 -0
  41. package/assets/templates/views/category/assembly/assembly.json +1 -0
  42. package/assets/templates/views/category/assembly/index.skeleton.tsx +64 -6
  43. package/assets/templates/views/category/assembly/registry.skeleton.tsx +2 -1
  44. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  45. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  46. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  47. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  48. package/assets/templates/views/category/index.tsx +76 -7
  49. package/assets/templates/views/category/props.ts +7 -0
  50. package/assets/templates/views/category/registry.tsx +19 -0
  51. package/assets/templates/views/category/schema.ts +13 -0
  52. package/assets/templates/views/home/assembly/assembly.json +9 -0
  53. package/assets/templates/views/home/assembly/index.skeleton.tsx +22 -4
  54. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +1 -1
  55. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +2 -2
  56. package/assets/templates/views/home/data.ts +9 -0
  57. package/assets/templates/views/home/index.tsx +23 -5
  58. package/assets/templates/views/home/registry.tsx +2 -2
  59. package/assets/templates/views/product/data.ts +4 -2
  60. package/assets/templates/views/product/index.tsx +49 -5
  61. package/assets/templates/views/product/props.ts +7 -0
  62. package/assets/templates/views/product/registry.tsx +25 -0
  63. package/assets/templates/views/product/schema.ts +13 -0
  64. package/assets/templates/views/static/content.ts +4 -4
  65. package/assets/templates/views/static/index.tsx +1 -1
  66. package/bin/storefront.js +1231 -154
  67. package/examples/merchandising/category-led.json +37 -0
  68. package/examples/merchandising/collection-led.json +37 -0
  69. package/package.json +11 -7
  70. package/recipes/archetypes.json +9 -22
  71. package/recipes/catalog.json +40 -1
  72. package/recipes/features.json +25 -2
  73. package/recipes/recipe.schema.json +1 -1
  74. package/recipes/recipes.json +32 -30
  75. package/recipes/sections.json +10 -0
  76. package/recipes/validate.mjs +54 -0
  77. package/scripts/candidate.mjs +4 -4
  78. package/scripts/composers.mjs +25 -5
  79. package/scripts/env-file.mjs +86 -0
  80. package/scripts/file-classes.mjs +5 -0
  81. package/scripts/migrate-english-routes.mjs +276 -0
  82. package/scripts/pinning.mjs +74 -5
  83. package/scripts/plan-apply.mjs +149 -5
  84. package/scripts/reconcile.mjs +49 -11
  85. package/scripts/render-templates.mjs +97 -44
  86. package/scripts/section-config.mjs +253 -0
  87. package/scripts/starter.mjs +17 -0
  88. package/scripts/theme/color.mjs +185 -0
  89. package/scripts/theme/css-theme.mjs +233 -0
  90. package/scripts/theme/fonts.mjs +123 -0
  91. package/scripts/theme/import-export.mjs +125 -0
  92. package/scripts/theme/preset.mjs +93 -0
  93. package/scripts/theme/theme-plan.mjs +292 -0
  94. package/scripts/theme/tokens.mjs +137 -0
  95. package/scripts/view-assembly.mjs +47 -1
  96. package/skills/sf-feature/SKILL.md +32 -19
  97. package/skills/sf-setup/SKILL.md +79 -0
  98. package/skills/sf-theme-gen/SKILL.md +99 -36
  99. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  100. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  101. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  102. package/skills/sf-view-gen/SKILL.md +63 -40
  103. package/skills/sf-view-gen/assets/recipes/archetypes.json +9 -22
  104. package/skills/sf-view-gen/assets/recipes/catalog.json +40 -1
  105. package/skills/sf-view-gen/assets/recipes/features.json +25 -2
  106. package/skills/sf-view-gen/assets/recipes/recipes.json +32 -30
  107. package/skills/sf-view-gen/assets/recipes/sections.json +10 -0
  108. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  109. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  110. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  111. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  112. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  113. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  114. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  115. package/skills/sf-view-gen/references/views.md +28 -37
  116. package/skills/sf-view-gen/scripts/validate.mjs +54 -0
  117. package/skills/storefront-workflow/SKILL.md +94 -31
  118. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  119. package/CHANGELOG.md +0 -84
  120. package/assets/templates/views/user-panel/index.tsx +0 -106
  121. package/assets/templates/views/user-panel/registry.tsx +0 -54
  122. package/assets/templates/views/user-panel/schema.ts +0 -79
  123. package/commands/sf-add-view.md +0 -57
  124. package/commands/sf-feature.md +0 -28
  125. package/commands/sf-theme.md +0 -26
  126. package/commands/sf-view.md +0 -67
  127. package/skills/building-storefronts/SKILL.md +0 -178
  128. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  129. package/skills/json-render-remotion/SKILL.md +0 -111
  130. package/skills/json-render-solid/SKILL.md +0 -204
  131. package/skills/nextjs-shadcn/SKILL.md +0 -367
  132. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  133. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  134. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  135. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  136. package/skills/nextjs-shadcn/references/styling.md +0 -555
  137. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  138. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  139. package/skills/shadcn-theming/SKILL.md +0 -104
  140. package/skills/storefront-best-practices/SKILL.md +0 -421
  141. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  142. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  143. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  144. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  145. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  146. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  147. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  148. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  149. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  150. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  151. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  152. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  153. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  154. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  155. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  156. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  157. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  158. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  159. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  160. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  161. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  162. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  163. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  164. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  165. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  166. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -24,13 +24,13 @@ import {
24
24
  mapCatalogResultsToFilterProps,
25
25
  } from '@thorprovider/runtime/commerce'
26
26
  import { getDataSource, getStoreContext, isRealMode } from '../../store-context'
27
- import { mountedFeatures, mountedFeatureConfigs } from '../../composition'
27
+ import { mountedFeatureConfigs, mountedFeatureNames, mountedFeatures } from '../../composition'
28
28
  import { pageRoot } from '../../spec/page-root'
29
29
  import type { StorefrontElement } from '../../spec/types'
30
30
  import type { ViewModule } from '../types'
31
31
  import { registry } from './registry'
32
- import { categoryFilterProps } from './props'
33
- import { categoryFilterPropsSchema } from './schema'
32
+ import { categoryBreadcrumbProps, categoryFilterProps } from './props'
33
+ import { breadcrumbPropsSchema, categoryFilterPropsSchema } from './schema'
34
34
 
35
35
  // The single controlled `CategoryFilter2` base listing. The search/filter
36
36
  // controls are shown only when the matching feature is installed; the base
@@ -38,6 +38,10 @@ import { categoryFilterPropsSchema } from './schema'
38
38
  // and pagination.
39
39
  const hasSearch = mountedFeatures.includes('catalog-search')
40
40
  const hasFilters = mountedFeatures.includes('category-filters')
41
+ // The navigability feature contributes the entity-derived breadcrumb trail. When
42
+ // it is not installed the trail is empty and the Breadcrumb block renders
43
+ // nothing, so removal leaves no dangling navigation aid.
44
+ const hasNavigability = mountedFeatureNames.includes('navigability')
41
45
  const searchConfig = (mountedFeatureConfigs['catalog-search'] ?? {}) as { placeholder?: string }
42
46
  const filtersConfig = (mountedFeatureConfigs['category-filters'] ?? {}) as {
43
47
  filterSet?: string[]
@@ -53,6 +57,13 @@ const SORT_LABELS: Record<string, string> = {
53
57
  }
54
58
 
55
59
  const elements: Record<string, StorefrontElement> = {
60
+ 'category.breadcrumb': {
61
+ type: 'Breadcrumb',
62
+ props: {
63
+ ...categoryBreadcrumbProps,
64
+ items: { $state: '/breadcrumbItems' },
65
+ },
66
+ },
56
67
  'category.filter': {
57
68
  type: 'CategoryFilter2',
58
69
  props: {
@@ -90,6 +101,10 @@ const elements: Record<string, StorefrontElement> = {
90
101
  }
91
102
 
92
103
  const catalog = {
104
+ Breadcrumb: {
105
+ props: breadcrumbPropsSchema,
106
+ description: 'Migas de pan derivadas de la jerarquía real de categorías.',
107
+ },
93
108
  CategoryFilter2: {
94
109
  props: categoryFilterPropsSchema,
95
110
  description:
@@ -112,22 +127,74 @@ const emptyFilterState = () => ({
112
127
  categoryFilterCategories: [],
113
128
  categoryFilterCollections: [],
114
129
  categoryFilterPriceRange: undefined,
130
+ breadcrumbItems: [] as { label: string; href?: string }[],
115
131
  })
116
132
 
133
+ /**
134
+ * The real category entity for the current route scope, resolved from the
135
+ * backend catalog. Entity SEO reads this; an unknown/absent scope yields no
136
+ * entity (no metadata or structured data is fabricated).
137
+ */
138
+ function resolveCategoryEntity(
139
+ slug: string | undefined,
140
+ catalog: Awaited<ReturnType<typeof getCatalog>>,
141
+ ): { handle: string; name: string; description?: string } | undefined {
142
+ if (!slug) return undefined
143
+ const match = catalog.categoriesRaw.find((entry) => entry.handle === slug)
144
+ if (!match) return undefined
145
+ const description =
146
+ typeof match.description === 'string' && match.description.trim() !== ''
147
+ ? match.description
148
+ : undefined
149
+ return {
150
+ handle: match.handle,
151
+ name: match.name,
152
+ ...(description !== undefined ? { description } : {}),
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Entity-derived breadcrumb trail for the current category scope: Home, the
158
+ * real ancestor chain (root -> leaf), and the current category. Derived from
159
+ * the backend category hierarchy (`parentCategoryId`); no level is fabricated,
160
+ * a cycle cannot loop forever, and no trail is produced when the navigability
161
+ * feature is not installed or the scope is unknown.
162
+ */
163
+ function resolveBreadcrumbItems(
164
+ slug: string | undefined,
165
+ catalog: Awaited<ReturnType<typeof getCatalog>>,
166
+ ): { label: string; href?: string }[] {
167
+ if (!hasNavigability || !slug) return []
168
+ const byHandle = new Map(catalog.categoriesRaw.map((entry) => [entry.handle, entry]))
169
+ const byId = new Map(catalog.categoriesRaw.map((entry) => [entry.id, entry]))
170
+ const chain: { label: string; href: string }[] = []
171
+ const visited = new Set<string>()
172
+ let current = byHandle.get(slug)
173
+ while (current && !visited.has(current.handle)) {
174
+ visited.add(current.handle)
175
+ chain.unshift({ label: current.name, href: `/categories/${current.handle}` })
176
+ current = current.parentCategoryId ? byId.get(current.parentCategoryId) : undefined
177
+ }
178
+ if (chain.length === 0) return []
179
+ const leaf = chain[chain.length - 1]
180
+ // The current (leaf) item is not a link; only ancestors are navigable.
181
+ return [{ label: 'Inicio', href: '/' }, ...chain.slice(0, -1), { label: leaf.label }]
182
+ }
183
+
117
184
  export const category: ViewModule = {
118
185
  name: 'category',
119
186
  elements,
120
187
  catalog,
121
188
  registry,
122
189
  route: {
123
- path: '/categorias/[[...slug]]',
190
+ path: '/categories/[[...slug]]',
124
191
  loaderName: 'category',
125
192
  layout: 'default',
126
193
  metadata: {
127
194
  title: 'Categoría',
128
195
  description: 'Explora los productos de esta categoría en __SITE_NAME__.',
129
196
  },
130
- entry: pageRoot('category', ['category.filter'], elements),
197
+ entry: pageRoot('category', ['category.breadcrumb', 'category.filter'], elements),
131
198
  },
132
199
  loader: {
133
200
  category: async (params, searchParams) => {
@@ -181,13 +248,13 @@ export const category: ViewModule = {
181
248
  const presentation = mapCatalogResultsToFilterProps({
182
249
  query: listing.query,
183
250
  results: listing.results,
184
- // A scoped listing (`/categorias/<handle>`) must not offer the
251
+ // A scoped listing (`/categories/<handle>`) must not offer the
185
252
  // category facet: the scope already fixes the category. The root
186
253
  // catalog passes no scope and keeps it.
187
254
  ...(slug ? { categoryScope: slug } : {}),
188
255
  // Product links point at the product-detail route. The runtime stays
189
256
  // route-agnostic, so this view supplies the href builder.
190
- productHref: (handle: string) => `/productos/${handle}`,
257
+ productHref: (handle: string) => `/products/${handle}`,
191
258
  ...(categories ? { categories } : {}),
192
259
  ...(collections ? { collections } : {}),
193
260
  sortOptions,
@@ -199,6 +266,8 @@ export const category: ViewModule = {
199
266
 
200
267
  return {
201
268
  storeCategories: catalog.categories,
269
+ categoryEntity: resolveCategoryEntity(slug, catalog),
270
+ breadcrumbItems: resolveBreadcrumbItems(slug, catalog),
202
271
  dataSource: catalog.source,
203
272
  dataError: null,
204
273
  categoryFilterProducts: presentation.products,
@@ -14,3 +14,10 @@ import { filterContent } from './content'
14
14
  export const categoryFilterProps = {
15
15
  ...filterContent,
16
16
  }
17
+
18
+ // Presentational defaults for the breadcrumb; the trail itself is bound to
19
+ // `$state` from the loader (entity-derived, never invented).
20
+ export const categoryBreadcrumbProps = {
21
+ ariaLabel: 'Migas de pan',
22
+ backLabel: 'Volver',
23
+ }
@@ -15,6 +15,7 @@ import {
15
15
  type CategoryFilter2Filters,
16
16
  type CategoryFilter2Props,
17
17
  } from '@thorprovider/blocks'
18
+ import { Breadcrumb as BreadcrumbBlock } from '@thorprovider/blocks'
18
19
  import { buildCategoryQueryHref, catalogHistoryMode, type CategoryQueryChange } from '@thorprovider/runtime/commerce'
19
20
  import type { CatalogFeatureName } from '@thorprovider/runtime/contracts'
20
21
  import { useStateStore } from '@json-render/react'
@@ -29,6 +30,24 @@ type CategoryFilter2RegistryProps = CategoryFilter2Props & {
29
30
  }
30
31
 
31
32
  export const registry: ViewRegistry = {
33
+ Breadcrumb: ({ props }) => {
34
+ const p = props as {
35
+ items: { label: string; href?: string }[]
36
+ maxItems?: number
37
+ ariaLabel?: string
38
+ backLabel?: string
39
+ className?: string
40
+ }
41
+ return (
42
+ <BreadcrumbBlock
43
+ items={p.items}
44
+ maxItems={p.maxItems}
45
+ ariaLabel={p.ariaLabel}
46
+ backLabel={p.backLabel}
47
+ className={p.className}
48
+ />
49
+ )
50
+ },
32
51
  CategoryFilter2: ({ props, emit }) => {
33
52
  const router = useRouter()
34
53
  const pathname = usePathname()
@@ -75,4 +75,17 @@ export const categoryFilterPropsSchema = z
75
75
  })
76
76
  .catchall(z.unknown())
77
77
 
78
+ export const breadcrumbItemSchema = z.object({
79
+ label: z.string(),
80
+ href: z.string().optional(),
81
+ })
82
+
83
+ export const breadcrumbPropsSchema = z.object({
84
+ items: z.array(breadcrumbItemSchema),
85
+ maxItems: z.number().int().min(2).optional(),
86
+ ariaLabel: z.string().optional(),
87
+ backLabel: z.string().optional(),
88
+ className: z.string().optional(),
89
+ })
90
+
78
91
  export type CategoryFilterProps = z.infer<typeof categoryFilterPropsSchema>
@@ -19,6 +19,15 @@
19
19
  "registryExport": "ctaRegistry",
20
20
  "propsModule": "./content",
21
21
  "defaultProps": "{}"
22
+ },
23
+ "products": {
24
+ "elementType": "Products",
25
+ "registryModule": "../sections/products.client",
26
+ "registryExport": "productsRegistry",
27
+ "propsModule": "./content",
28
+ "defaultProps": "{}",
29
+ "dataBound": true,
30
+ "defaultVariant": "ProductCard1"
22
31
  }
23
32
  }
24
33
  }
@@ -5,14 +5,15 @@
5
5
  // on the server. Registered by importing it in ../index.ts. Recipe: recipes.json -> "home".
6
6
 
7
7
  import { getCatalog } from '@thorprovider/runtime/commerce'
8
- import { getDataSource } from '../../store-context'
8
+ import { getDataSource, getStoreContext } from '../../store-context'
9
9
  import { pageRoot } from '../../spec/page-root'
10
10
  import type { StorefrontElement } from '../../spec/types'
11
11
  import type { ViewModule } from '../types'
12
12
  import { registry } from './registry'
13
13
  import { benefitsProps, categoriesProps, heroProps } from './props'
14
- import { selectHeroProducts, toNavCategories } from './data'
14
+ import * as homeData from './data'
15
15
  import { toCategoryCard, toFeaturedProduct } from '../../mappers'
16
+ import { resolveSectionSources, resolveSectionViewStates } from '../../section-sources'
16
17
  import {
17
18
  categoryCardsPropsSchema,
18
19
  featurePropsSchema,
@@ -46,14 +47,30 @@ export const home: ViewModule = {
46
47
  },
47
48
  loader: {
48
49
  home: async () => {
50
+ // Data-bound sections resolve independently through the canonical catalog
51
+ // bridge; equal queries share request-local work and differing contexts
52
+ // stay isolated. A section failure never erases its siblings, and it is
53
+ // resolved separately so a catalog failure still yields truthful
54
+ // per-section state instead of a missing one.
55
+ let sections: Record<string, unknown> = {}
56
+ try {
57
+ sections = await resolveSectionViewStates(
58
+ @@SECTION_LOADERS@@,
59
+ resolveSectionSources(homeData),
60
+ getStoreContext(),
61
+ )
62
+ } catch {
63
+ sections = {}
64
+ }
49
65
  try {
50
66
  const { products, categories, source } = await getCatalog()
51
67
  return {
52
- heroProducts: selectHeroProducts(products).map(toFeaturedProduct),
68
+ heroProducts: homeData.selectHeroProducts(products).map(toFeaturedProduct),
53
69
  categories: categories.map(toCategoryCard),
54
- navCategories: toNavCategories(categories),
70
+ navCategories: homeData.toNavCategories(categories),
55
71
  dataSource: source,
56
72
  dataError: null,
73
+ sections,
57
74
  }
58
75
  } catch (error) {
59
76
  // A real-mode failure is surfaced as a visible error, never as fictional
@@ -65,6 +82,7 @@ export const home: ViewModule = {
65
82
  dataSource: getDataSource(),
66
83
  dataError:
67
84
  error instanceof Error ? error.message : 'No se pudo cargar el catálogo.',
85
+ sections,
68
86
  }
69
87
  }
70
88
  },
@@ -1,5 +1,5 @@
1
1
  'home.hero': {
2
2
  type: 'StorefrontHero',
3
3
  props: heroProps,
4
- on: { addToCart: { action: 'addToCart' }, openProduct: { action: 'openProduct' } },
4
+ on: { addClickedToCart: { action: 'addClickedToCart' }, openProduct: { action: 'openProduct' } },
5
5
  },
@@ -12,9 +12,9 @@
12
12
  secondaryCta={hero.secondaryCta}
13
13
  heroImage={hero.heroImage}
14
14
  onAddToCart={(product) => {
15
- // No first-variant shortcut: open the detail so a variant is chosen.
15
+ // Product-card shortcut: add the first purchasable variant.
16
16
  set('/lastClicked', product)
17
- emit('openProduct')
17
+ emit('addClickedToCart')
18
18
  }}
19
19
  onBuyNow={(product) => {
20
20
  set('/lastClicked', product)
@@ -13,3 +13,12 @@ export function toNavCategories(
13
13
  ): { label: string; href: string }[] {
14
14
  return categories.map((category) => ({ label: category.label, href: category.href }))
15
15
  }
16
+
17
+ // Per-instance selection for data-bound sections (for example `products`), keyed
18
+ // by the section's stable id from the composition manifest. Each value is a
19
+ // canonical catalog selection (category/collection/search/ordering) that the
20
+ // generated host loader resolves through the runtime catalog bridge. Left empty
21
+ // by default: a data-bound section with no entry renders an `unconfigured`
22
+ // state. Entries whose id is no longer referenced by the composition are kept
23
+ // but never queried, so re-adding the section with the same id restores them.
24
+ export const sectionSources: Record<string, unknown> = {}
@@ -5,14 +5,15 @@
5
5
  // on the server. Registered by importing it in ../index.ts. Recipe: recipes.json -> "home".
6
6
 
7
7
  import { getCatalog } from '@thorprovider/runtime/commerce'
8
- import { getDataSource } from '../../store-context'
8
+ import { getDataSource, getStoreContext } from '../../store-context'
9
9
  import { pageRoot } from '../../spec/page-root'
10
10
  import type { StorefrontElement } from '../../spec/types'
11
11
  import type { ViewModule } from '../types'
12
12
  import { registry } from './registry'
13
13
  import { benefitsProps, categoriesProps, heroProps } from './props'
14
- import { selectHeroProducts, toNavCategories } from './data'
14
+ import * as homeData from './data'
15
15
  import { toCategoryCard, toFeaturedProduct } from '../../mappers'
16
+ import { resolveSectionSources, resolveSectionViewStates } from '../../section-sources'
16
17
  import {
17
18
  categoryCardsPropsSchema,
18
19
  featurePropsSchema,
@@ -25,7 +26,7 @@ const elements: Record<string, StorefrontElement> = {
25
26
  'home.hero': {
26
27
  type: 'StorefrontHero',
27
28
  props: heroProps,
28
- on: { addToCart: { action: 'addToCart' }, openProduct: { action: 'openProduct' } },
29
+ on: { addClickedToCart: { action: 'addClickedToCart' }, openProduct: { action: 'openProduct' } },
29
30
  },
30
31
  'home.benefits': {
31
32
  type: 'Feature',
@@ -70,14 +71,30 @@ export const home: ViewModule = {
70
71
  },
71
72
  loader: {
72
73
  home: async () => {
74
+ // Data-bound sections resolve independently through the canonical catalog
75
+ // bridge; equal queries share request-local work and differing contexts
76
+ // stay isolated. A section failure never erases its siblings, and it is
77
+ // resolved separately so a catalog failure still yields truthful
78
+ // per-section state instead of a missing one.
79
+ let sections: Record<string, unknown> = {}
80
+ try {
81
+ sections = await resolveSectionViewStates(
82
+ [],
83
+ resolveSectionSources(homeData),
84
+ getStoreContext(),
85
+ )
86
+ } catch {
87
+ sections = {}
88
+ }
73
89
  try {
74
90
  const { products, categories, source } = await getCatalog()
75
91
  return {
76
- heroProducts: selectHeroProducts(products).map(toFeaturedProduct),
92
+ heroProducts: homeData.selectHeroProducts(products).map(toFeaturedProduct),
77
93
  categories: categories.map(toCategoryCard),
78
- navCategories: toNavCategories(categories),
94
+ navCategories: homeData.toNavCategories(categories),
79
95
  dataSource: source,
80
96
  dataError: null,
97
+ sections,
81
98
  }
82
99
  } catch (error) {
83
100
  // A real-mode failure is surfaced as a visible error, never as fictional
@@ -89,6 +106,7 @@ export const home: ViewModule = {
89
106
  dataSource: getDataSource(),
90
107
  dataError:
91
108
  error instanceof Error ? error.message : 'No se pudo cargar el catálogo.',
109
+ sections,
92
110
  }
93
111
  }
94
112
  },
@@ -39,9 +39,9 @@ export const registry: ViewRegistry = {
39
39
  secondaryCta={hero.secondaryCta}
40
40
  heroImage={hero.heroImage}
41
41
  onAddToCart={(product) => {
42
- // No first-variant shortcut: open the detail so a variant is chosen.
42
+ // Product-card shortcut: add the first purchasable variant.
43
43
  set('/lastClicked', product)
44
- emit('openProduct')
44
+ emit('addClickedToCart')
45
45
  }}
46
46
  onBuyNow={(product) => {
47
47
  set('/lastClicked', product)
@@ -40,8 +40,10 @@ export function buildSelectedProduct(
40
40
  }
41
41
 
42
42
  /**
43
- * No first-variant shortcut: nothing is selected until the shopper explicitly
44
- * chooses a variant, so `onAddToCart` can never silently add the first one.
43
+ * No first-variant shortcut on the product detail surface: nothing is selected
44
+ * until the shopper explicitly chooses a variant, so the detail's `onAddToCart`
45
+ * can never silently add the first one. Listing cards use the separate
46
+ * first-purchasable-variant shortcut instead.
45
47
  */
46
48
  export function initialSelectedVariantId(): string | null {
47
49
  return null
@@ -7,17 +7,29 @@
7
7
  import { notFound } from 'next/navigation'
8
8
  import { loadProductDetail } from '../../commerce'
9
9
  import { getDataSource } from '../../store-context'
10
+ import { mountedFeatureNames } from '../../composition'
10
11
  import { pageRoot } from '../../spec/page-root'
11
12
  import type { StorefrontElement } from '../../spec/types'
12
13
  import type { ViewModule } from '../types'
13
14
  import { registry } from './registry'
14
- import { productOverviewProps, productReviewsProps } from './props'
15
+ import { productBreadcrumbProps, productOverviewProps, productReviewsProps } from './props'
15
16
  import { buildSelectedProduct, initialSelectedVariantId } from './data'
16
- import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
17
+ import { breadcrumbPropsSchema, productOverviewPropsSchema, reviewsPropsSchema } from './schema'
17
18
 
18
19
  // The element tree is derived wiring; the rendered props come from the
19
20
  // user-owned ./props.ts module so they survive sync and unit removal.
20
21
  const elements: Record<string, StorefrontElement> = {
22
+ // The navigability feature contributes the entity-derived breadcrumb trail.
23
+ // The element is always wired; the loader returns an empty trail when the
24
+ // feature is not installed or the backend supplies no category, so the block
25
+ // renders nothing and removal leaves no dangling navigation aid.
26
+ 'product.breadcrumb': {
27
+ type: 'Breadcrumb',
28
+ props: {
29
+ ...productBreadcrumbProps,
30
+ items: { $state: '/breadcrumbItems' },
31
+ },
32
+ },
21
33
  'product.overview': {
22
34
  type: 'ProductOverview',
23
35
  visible: { $state: '/selectedProduct' },
@@ -38,6 +50,10 @@ const elements: Record<string, StorefrontElement> = {
38
50
  }
39
51
 
40
52
  const catalog = {
53
+ Breadcrumb: {
54
+ props: breadcrumbPropsSchema,
55
+ description: 'Migas de pan derivadas de la jerarquía real de categorías del producto.',
56
+ },
41
57
  ProductOverview: {
42
58
  props: productOverviewPropsSchema,
43
59
  description: 'Detalle de producto: galería, precio, variantes y añadir al carrito.',
@@ -48,20 +64,42 @@ const catalog = {
48
64
  },
49
65
  }
50
66
 
67
+ const hasNavigability = mountedFeatureNames.includes('navigability')
68
+
69
+ // Entity-derived trail only: the product's real ancestor categories. When the
70
+ // feature is absent or the backend supplies no category, the trail is empty and
71
+ // the Breadcrumb block renders nothing (no level is ever invented).
72
+ function resolveProductBreadcrumbItems(
73
+ title: string,
74
+ categoryPath: { handle: string; name: string }[] | undefined,
75
+ ): { label: string; href?: string }[] {
76
+ if (!hasNavigability || !categoryPath || categoryPath.length === 0) return []
77
+ const chain = categoryPath.map((entry) => ({
78
+ label: entry.name,
79
+ href: `/categories/${entry.handle}`,
80
+ }))
81
+ // Home > ancestor categories (links) > the product itself (current page).
82
+ return [{ label: 'Inicio', href: '/' }, ...chain, { label: title }]
83
+ }
84
+
51
85
  export const product: ViewModule = {
52
86
  name: 'product',
53
87
  elements,
54
88
  catalog,
55
89
  registry,
56
90
  route: {
57
- path: '/productos/[id]',
91
+ path: '/products/[id]',
58
92
  loaderName: 'product',
59
93
  layout: 'default',
60
94
  metadata: {
61
95
  title: 'Producto',
62
96
  description: 'Detalles del producto seleccionado.',
63
97
  },
64
- entry: pageRoot('product', ['product.overview', 'product.reviews'], elements),
98
+ entry: pageRoot(
99
+ 'product',
100
+ ['product.breadcrumb', 'product.overview', 'product.reviews'],
101
+ elements,
102
+ ),
65
103
  },
66
104
  loader: {
67
105
  product: async (params) => {
@@ -79,6 +117,7 @@ export const product: ViewModule = {
79
117
  productQuantity: 1,
80
118
  productReviews: [],
81
119
  hasProductReviews: false,
120
+ breadcrumbItems: [],
82
121
  dataSource,
83
122
  dataError:
84
123
  error instanceof Error ? error.message : 'No se pudo cargar el producto.',
@@ -91,11 +130,16 @@ export const product: ViewModule = {
91
130
 
92
131
  return {
93
132
  selectedProduct: buildSelectedProduct(result.detail),
94
- // No first-variant shortcut: the shopper must select explicitly.
133
+ // No first-variant shortcut on the detail surface: the shopper must
134
+ // select explicitly (listing cards use the card shortcut instead).
95
135
  selectedVariantId: initialSelectedVariantId(),
96
136
  productQuantity: 1,
97
137
  productReviews: [],
98
138
  hasProductReviews: false,
139
+ breadcrumbItems: resolveProductBreadcrumbItems(
140
+ result.detail.title,
141
+ result.detail.categoryPath,
142
+ ),
99
143
  dataSource: result.source,
100
144
  dataError: null,
101
145
  }
@@ -18,3 +18,10 @@ export const productReviewsProps = {
18
18
  ...reviewsCopy,
19
19
  reviews: { $state: '/productReviews' },
20
20
  }
21
+
22
+ // Presentational defaults only; the entity-derived trail is bound to
23
+ // `$state: '/productBreadcrumbItems'` in the element tree, never invented here.
24
+ export const productBreadcrumbProps = {
25
+ ariaLabel: 'Migas de pan',
26
+ backLabel: 'Volver',
27
+ }
@@ -7,18 +7,43 @@
7
7
  // spec (elements/catalog/route/loader) stays a pure Server Component module.
8
8
 
9
9
  import {
10
+ Breadcrumb as BreadcrumbBlock,
10
11
  ProductOverview1 as ProductOverviewBlock,
11
12
  ReviewRating1 as ReviewsBlock,
12
13
  } from '@thorprovider/blocks'
13
14
  import { isAvailableMoney } from '@thorprovider/blocks/contracts'
14
15
  import { useStateStore } from '@json-render/react'
16
+ import { useProductVisitObservation } from '../../section-observation'
15
17
  import type { ViewRegistry } from '../types'
16
18
  import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
17
19
 
18
20
  export const registry: ViewRegistry = {
21
+ Breadcrumb: ({ props }) => {
22
+ const p = props as {
23
+ items: { label: string; href?: string }[]
24
+ maxItems?: number
25
+ ariaLabel?: string
26
+ backLabel?: string
27
+ className?: string
28
+ }
29
+ return (
30
+ <BreadcrumbBlock
31
+ items={p.items}
32
+ maxItems={p.maxItems}
33
+ ariaLabel={p.ariaLabel}
34
+ backLabel={p.backLabel}
35
+ className={p.className}
36
+ />
37
+ )
38
+ },
19
39
  ProductOverview: ({ props, emit }) => {
20
40
  const { set } = useStateStore()
21
41
  const parsed = productOverviewPropsSchema.parse(props)
42
+ // A successfully rendered product destination: recorded only when it matches
43
+ // the section origin (disabled by default; never attributes a guess). The
44
+ // stable public identity is the handle, which is what section clicks record.
45
+ const productHandle = parsed.product.handle ?? parsed.product.id
46
+ useProductVisitObservation(productHandle, `/products/${productHandle}`)
22
47
  return (
23
48
  <ProductOverviewBlock
24
49
  product={parsed.product}
@@ -101,6 +101,19 @@ export const reviewsPropsSchema = z.object({
101
101
  reviewsPerPage: z.number().optional(),
102
102
  })
103
103
 
104
+ export const breadcrumbItemSchema = z.object({
105
+ label: z.string(),
106
+ href: z.string().optional(),
107
+ })
108
+
109
+ export const breadcrumbPropsSchema = z.object({
110
+ items: z.array(breadcrumbItemSchema),
111
+ maxItems: z.number().int().min(2).optional(),
112
+ ariaLabel: z.string().optional(),
113
+ backLabel: z.string().optional(),
114
+ className: z.string().optional(),
115
+ })
116
+
104
117
  export type ProductDetailProductSpec = z.infer<typeof productDetailProductSchema>
105
118
  export type ProductOverviewProps = z.infer<typeof productOverviewPropsSchema>
106
119
  export type Review = z.infer<typeof reviewSchema>
@@ -13,7 +13,7 @@ import type { StaticPageContent } from './schema'
13
13
 
14
14
  export const staticPagesContent: StaticPageContent[] = [
15
15
  {
16
- path: '/contacto',
16
+ path: '/contact',
17
17
  title: 'Contacto',
18
18
  description: 'Cómo comunicarte con la tienda.',
19
19
  sections: [
@@ -33,7 +33,7 @@ export const staticPagesContent: StaticPageContent[] = [
33
33
  ],
34
34
  },
35
35
  {
36
- path: '/terminos',
36
+ path: '/terms',
37
37
  title: 'Términos y condiciones',
38
38
  description: 'Condiciones de uso de la tienda en línea.',
39
39
  sections: [
@@ -57,7 +57,7 @@ export const staticPagesContent: StaticPageContent[] = [
57
57
  ],
58
58
  },
59
59
  {
60
- path: '/privacidad',
60
+ path: '/privacy',
61
61
  title: 'Política de privacidad',
62
62
  description: 'Cómo se tratan los datos en la tienda en línea.',
63
63
  sections: [
@@ -81,7 +81,7 @@ export const staticPagesContent: StaticPageContent[] = [
81
81
  ],
82
82
  },
83
83
  {
84
- path: '/sobre-nosotros',
84
+ path: '/about',
85
85
  title: 'Sobre nosotros',
86
86
  description: 'Información sobre la tienda.',
87
87
  sections: [
@@ -33,7 +33,7 @@ const catalog = {
33
33
  },
34
34
  }
35
35
 
36
- // A stable, unique root id per page: `/contacto` -> `static-contacto`,
36
+ // A stable, unique root id per page: `/contact` -> `static-contact`,
37
37
  // `/` -> `static-index`.
38
38
  const pageRootId = (path: string): string =>
39
39
  `static-${path.replace(/^\//, '').replace(/\//g, '-') || 'index'}`