@thorprovider/storefront 0.3.1 → 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 (263) hide show
  1. package/README.md +321 -152
  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 +54 -2
  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 +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,52 @@
1
+ // Thor Commerce view schema fragment: cart
2
+ // Zod mirrors of the block contracts for this view — the single source for the
3
+ // catalog entry and the client-side registry parsing (json-render does not
4
+ // validate props against the catalog itself, so the registry parses here).
5
+ // Kept free of React and server imports so the Server Component module and the
6
+ // 'use client' registry can share one contract. Block zod contracts are
7
+ // mirrored, not imported: @thorprovider/blocks ships zod v3 while the scaffold
8
+ // runs zod v4 and cross-major schema assignment breaks the catalog contract.
9
+
10
+ import { z } from 'zod'
11
+
12
+ // Canonical Money: a decimal string in major units ("69.99"), never cents.
13
+ const moneySchema = z.object({
14
+ amount: z.string(),
15
+ currencyCode: z.string(),
16
+ })
17
+
18
+ // Mirrors CartLineState: `id` is the backend line-item id used for absolute
19
+ // updates and removal; `lineTotal`/`unitPrice` are the authoritative backend
20
+ // amounts. An absent image stays absent (never serialized as "").
21
+ export const cartLineSchema = z.object({
22
+ id: z.string(),
23
+ merchandiseId: z.string(),
24
+ name: z.string(),
25
+ variant: z.string().optional(),
26
+ quantity: z.number(),
27
+ lineTotal: moneySchema,
28
+ unitPrice: moneySchema.optional(),
29
+ image: z.string().optional(),
30
+ handle: z.string().optional(),
31
+ })
32
+
33
+ // Mirrors CartTotalsState: backend-authoritative totals rendered verbatim.
34
+ export const cartTotalsSchema = z.object({
35
+ subtotal: moneySchema,
36
+ discount: moneySchema.optional(),
37
+ tax: moneySchema.optional(),
38
+ shipping: moneySchema.optional(),
39
+ total: moneySchema,
40
+ })
41
+
42
+ export const cartViewPropsSchema = z.object({
43
+ items: z.array(cartLineSchema),
44
+ totals: cartTotalsSchema.nullish(),
45
+ titleLabel: z.string().optional(),
46
+ checkoutLabel: z.string().optional(),
47
+ continueShoppingLabel: z.string().optional(),
48
+ emptyLabel: z.string().optional(),
49
+ readErrorLabel: z.string().optional(),
50
+ })
51
+
52
+ export type CartViewProps = z.infer<typeof cartViewPropsSchema>
@@ -0,0 +1,29 @@
1
+ {
2
+ "view": "category",
3
+ "slots": [
4
+ { "id": "category.breadcrumb", "slot": "breadcrumb", "default": "Breadcrumb" },
5
+ { "id": "category.filter", "slot": "filter", "default": "CategoryFilter2" },
6
+ {
7
+ "id": "category.productCard",
8
+ "slot": "productCard",
9
+ "default": "ProductCard1",
10
+ "nested": true
11
+ }
12
+ ],
13
+ "sections": {
14
+ "faq": {
15
+ "elementType": "Faq",
16
+ "registryModule": "../sections/faq.client",
17
+ "registryExport": "faqRegistry",
18
+ "propsModule": "./content",
19
+ "defaultProps": "{ items: [] }"
20
+ },
21
+ "cta": {
22
+ "elementType": "Cta",
23
+ "registryModule": "../sections/cta.client",
24
+ "registryExport": "ctaRegistry",
25
+ "propsModule": "./content",
26
+ "defaultProps": "{}"
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,251 @@
1
+ // Thor Commerce view module: category
2
+ // Server Component module: elements + catalog entries + route + loader only.
3
+ // The client-side component mappings live in ./registry.tsx ('use client'); this
4
+ // module imports them as data so the RSC graph never evaluates @json-render/react
5
+ // on the server. Registered by importing it in ../index.ts. Recipe: recipes.json -> "category".
6
+ //
7
+ // E2 task 6.1: the category listing is ONE controlled `CategoryFilter2` — the
8
+ // base listing slot `category.filter`, always present. The optional
9
+ // `catalog-search` / `category-filters` features are control contributions to
10
+ // that single block (logical mounts `category.search` / `category.filters`),
11
+ // never a second listing/controller. Their visibility and config are read from
12
+ // the installed manifest (`mountedFeatures` / `mountedFeatureConfigs`) that
13
+ // `sync` derives from `storefront.config.json`; no loader is hand-edited to add
14
+ // or remove them.
15
+
16
+ import { z } from 'zod'
17
+ import {
18
+ buildActiveFilterChips,
19
+ getCatalog,
20
+ getCatalogSortKeys,
21
+ getCollectionOptions,
22
+ loadCategoryListing,
23
+ mapCatalogOutcomeToStatus,
24
+ mapCatalogResultsToFilterProps,
25
+ } from '@thorprovider/runtime/commerce'
26
+ import { getDataSource, getStoreContext, isRealMode } from '../../store-context'
27
+ import { mountedFeatureConfigs, mountedFeatureNames, mountedFeatures } from '../../composition'
28
+ import { pageRoot } from '../../spec/page-root'
29
+ import type { StorefrontElement } from '../../spec/types'
30
+ import type { ViewModule } from '../types'
31
+ import { registry } from './registry'
32
+ import { categoryBreadcrumbProps, categoryFilterProps } from './props'
33
+ import { breadcrumbPropsSchema, categoryFilterPropsSchema } from './schema'@@SECTION_IMPORTS@@
34
+
35
+ // The single controlled `CategoryFilter2` base listing. The search/filter
36
+ // controls are shown only when the matching feature is installed; the base
37
+ // listing always owns the route scope, `sort`, `page`, `pageSize`, the products,
38
+ // and pagination.
39
+ const hasSearch = mountedFeatures.includes('catalog-search')
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')
45
+ const searchConfig = (mountedFeatureConfigs['catalog-search'] ?? {}) as { placeholder?: string }
46
+ const filtersConfig = (mountedFeatureConfigs['category-filters'] ?? {}) as {
47
+ filterSet?: string[]
48
+ showPrice?: boolean
49
+ }
50
+
51
+ // UI labels for the adapter-advertised sort keys. The keys themselves come from
52
+ // the provider (`getCatalogSortKeys`); no global price sort is ever assumed.
53
+ const SORT_LABELS: Record<string, string> = {
54
+ featured: 'Destacados',
55
+ name: 'Nombre',
56
+ newest: 'Más recientes',
57
+ }
58
+
59
+ const elements: Record<string, StorefrontElement> = {
60
+ @@ELEMENTS@@
61
+ }
62
+
63
+ const catalog = {
64
+ @@CATALOG@@
65
+ }
66
+
67
+ /** Empty state for the single listing, used on every failure path. */
68
+ const emptyFilterState = () => ({
69
+ categoryFilterProducts: [],
70
+ categoryFilterPagination: [] as (number | string)[],
71
+ categoryFilterActiveFilters: [],
72
+ categoryFilterSearchValue: '',
73
+ categoryFilterSelectedCategories: [] as string[],
74
+ categoryFilterSelectedCollections: [] as string[],
75
+ categoryFilterSelectedSort: 'featured',
76
+ categoryFilterSelectedPage: 1,
77
+ categoryFilterSelectedPageSize: 50,
78
+ categoryFilterSortOptions: [],
79
+ categoryFilterCategories: [],
80
+ categoryFilterCollections: [],
81
+ categoryFilterPriceRange: undefined,
82
+ breadcrumbItems: [] as { label: string; href?: string }[],
83
+ })
84
+
85
+ /**
86
+ * The real category entity for the current route scope, resolved from the
87
+ * backend catalog. Entity SEO reads this; an unknown/absent scope yields no
88
+ * entity (no metadata or structured data is fabricated).
89
+ */
90
+ function resolveCategoryEntity(
91
+ slug: string | undefined,
92
+ catalog: Awaited<ReturnType<typeof getCatalog>>,
93
+ ): { handle: string; name: string; description?: string } | undefined {
94
+ if (!slug) return undefined
95
+ const match = catalog.categoriesRaw.find((entry) => entry.handle === slug)
96
+ if (!match) return undefined
97
+ const description =
98
+ typeof match.description === 'string' && match.description.trim() !== ''
99
+ ? match.description
100
+ : undefined
101
+ return {
102
+ handle: match.handle,
103
+ name: match.name,
104
+ ...(description !== undefined ? { description } : {}),
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Entity-derived breadcrumb trail for the current category scope: Home, the
110
+ * real ancestor chain (root -> leaf), and the current category. Derived from
111
+ * the backend category hierarchy (`parentCategoryId`); no level is fabricated,
112
+ * a cycle cannot loop forever, and no trail is produced when the navigability
113
+ * feature is not installed or the scope is unknown.
114
+ */
115
+ function resolveBreadcrumbItems(
116
+ slug: string | undefined,
117
+ catalog: Awaited<ReturnType<typeof getCatalog>>,
118
+ ): { label: string; href?: string }[] {
119
+ if (!hasNavigability || !slug) return []
120
+ const byHandle = new Map(catalog.categoriesRaw.map((entry) => [entry.handle, entry]))
121
+ const byId = new Map(catalog.categoriesRaw.map((entry) => [entry.id, entry]))
122
+ const chain: { label: string; href: string }[] = []
123
+ const visited = new Set<string>()
124
+ let current = byHandle.get(slug)
125
+ while (current && !visited.has(current.handle)) {
126
+ visited.add(current.handle)
127
+ chain.unshift({ label: current.name, href: `/categories/${current.handle}` })
128
+ current = current.parentCategoryId ? byId.get(current.parentCategoryId) : undefined
129
+ }
130
+ if (chain.length === 0) return []
131
+ const leaf = chain[chain.length - 1]
132
+ // The current (leaf) item is not a link; only ancestors are navigable.
133
+ return [{ label: 'Inicio', href: '/' }, ...chain.slice(0, -1), { label: leaf.label }]
134
+ }
135
+
136
+ export const category: ViewModule = {
137
+ name: 'category',
138
+ elements,
139
+ catalog,
140
+ registry,
141
+ route: {
142
+ path: '/categories/[[...slug]]',
143
+ loaderName: 'category',
144
+ layout: 'default',
145
+ metadata: {
146
+ title: 'Categoría',
147
+ description: 'Explora los productos de esta categoría en __SITE_NAME__.',
148
+ },
149
+ entry: pageRoot('category', [@@SLOT_IDS@@], elements),
150
+ },
151
+ loader: {
152
+ category: async (params, searchParams) => {
153
+ const slug = Array.isArray(params.slug) ? params.slug[0] : params.slug
154
+ try {
155
+ // Validate the raw URL through the pure boundary before any commerce
156
+ // call. An invalid, repeated, or unknown parameter — or one owned by a
157
+ // feature that is not installed — returns a typed error with zero
158
+ // provider resolution, not even the sidebar catalog (E2 task 5.1/6.1).
159
+ const listing = await loadCategoryListing({
160
+ searchParams: searchParams ?? {},
161
+ ...(slug ? { categoryScope: slug } : {}),
162
+ mountedFeatures,
163
+ ...(isRealMode() ? { context: getStoreContext() } : {}),
164
+ })
165
+
166
+ if (!listing.ok) {
167
+ return {
168
+ storeCategories: [],
169
+ dataSource: getDataSource(),
170
+ dataError: null,
171
+ ...emptyFilterState(),
172
+ categoryFilterStatus: mapCatalogOutcomeToStatus({ ok: false, error: listing.error }),
173
+ }
174
+ }
175
+
176
+ const catalog = await getCatalog()
177
+ const advertisedSortKeys = await getCatalogSortKeys()
178
+ const sortOptions = advertisedSortKeys.map((key) => ({
179
+ value: key,
180
+ label: SORT_LABELS[key] ?? key,
181
+ }))
182
+ // Real adapter category options (handle + name). No fake counts/ranges.
183
+ const allCategories = catalog.categoriesRaw.map((entry) => ({
184
+ id: entry.handle,
185
+ label: entry.name,
186
+ }))
187
+ const filterSet = filtersConfig.filterSet ?? ['categories', 'collections']
188
+ const categories = filterSet.includes('categories') ? allCategories : undefined
189
+ // Real, backend-scoped collection options (handle + title), fetched only
190
+ // when `category-filters` is installed and owns the collections control.
191
+ // An empty backend list omits the control; no option is ever fabricated.
192
+ const showCollections = hasFilters && filterSet.includes('collections')
193
+ const allCollections = showCollections
194
+ ? (await getCollectionOptions()).map((entry) => ({
195
+ id: entry.id,
196
+ label: entry.label,
197
+ }))
198
+ : []
199
+ const collections = allCollections.length > 0 ? allCollections : undefined
200
+ const presentation = mapCatalogResultsToFilterProps({
201
+ query: listing.query,
202
+ results: listing.results,
203
+ // A scoped listing (`/categories/<handle>`) must not offer the
204
+ // category facet: the scope already fixes the category. The root
205
+ // catalog passes no scope and keeps it.
206
+ ...(slug ? { categoryScope: slug } : {}),
207
+ // Product links point at the product-detail route. The runtime stays
208
+ // route-agnostic, so this view supplies the href builder.
209
+ productHref: (handle: string) => `/products/${handle}`,
210
+ ...(categories ? { categories } : {}),
211
+ ...(collections ? { collections } : {}),
212
+ sortOptions,
213
+ })
214
+ const activeFilters = buildActiveFilterChips(listing.query, {
215
+ categories: Object.fromEntries(allCategories.map((entry) => [entry.id, entry.label])),
216
+ collections: Object.fromEntries(allCollections.map((entry) => [entry.id, entry.label])),
217
+ })
218
+
219
+ return {
220
+ storeCategories: catalog.categories,
221
+ categoryEntity: resolveCategoryEntity(slug, catalog),
222
+ breadcrumbItems: resolveBreadcrumbItems(slug, catalog),
223
+ dataSource: catalog.source,
224
+ dataError: null,
225
+ categoryFilterProducts: presentation.products,
226
+ categoryFilterPagination: presentation.pagination,
227
+ categoryFilterStatus: mapCatalogOutcomeToStatus({ ok: true, results: listing.results }),
228
+ categoryFilterActiveFilters: activeFilters,
229
+ categoryFilterSearchValue: presentation.searchValue,
230
+ categoryFilterSelectedCategories: presentation.selectedCategories,
231
+ categoryFilterSelectedCollections: presentation.selectedCollections,
232
+ categoryFilterSelectedSort: presentation.selectedSort,
233
+ categoryFilterSelectedPage: presentation.selectedPage,
234
+ categoryFilterSelectedPageSize: presentation.selectedPageSize,
235
+ categoryFilterSortOptions: presentation.sortOptions ?? [],
236
+ categoryFilterCategories: presentation.categories ?? [],
237
+ categoryFilterCollections: presentation.collections ?? [],
238
+ categoryFilterPriceRange: presentation.priceRange,
239
+ }
240
+ } catch (error) {
241
+ return {
242
+ storeCategories: [],
243
+ dataSource: getDataSource(),
244
+ dataError: error instanceof Error ? error.message : 'No se pudo cargar la categoría.',
245
+ ...emptyFilterState(),
246
+ categoryFilterStatus: { state: 'error' as const },
247
+ }
248
+ }
249
+ },
250
+ },
251
+ }
@@ -0,0 +1,34 @@
1
+ 'use client'
2
+
3
+ // Thor Commerce view registry fragment: category
4
+ // The category view's single client boundary: one controlled `CategoryFilter2`
5
+ // (the base listing slot `category.filter`). Search/filters/sort/pagination are
6
+ // URL-backed: the block emits a change, this registry rewrites the URL through
7
+ // the pure `buildCategoryQueryHref` seam, and the server loader re-seeds the
8
+ // controlled props from the new URL. No fetch and no provider call happens on
9
+ // the client. The search and filter controls are only present because the
10
+ // installed manifest enabled them; the block never adds a second listing.
11
+
12
+ import {
13
+ CategoryFilter2 as CategoryFilter2Block,
14
+ type CategoryFilter2ActiveFilter,
15
+ type CategoryFilter2Filters,
16
+ type CategoryFilter2Props,
17
+ } from '@thorprovider/blocks'
18
+ @@BLOCK_IMPORTS@@@@SECTION_REGISTRY_IMPORTS@@
19
+ import { buildCategoryQueryHref, catalogHistoryMode, type CategoryQueryChange } from '@thorprovider/runtime/commerce'
20
+ import type { CatalogFeatureName } from '@thorprovider/runtime/contracts'
21
+ import { useStateStore } from '@json-render/react'
22
+ import { usePathname, useRouter } from 'next/navigation'
23
+ import type { ViewRegistry } from '../types'
24
+
25
+ // The generated view module injects the installed-feature context as an element
26
+ // prop (task 4.11); it is composition context, not a `CategoryFilter2` prop, so
27
+ // it is read separately from the block props.
28
+ type CategoryFilter2RegistryProps = CategoryFilter2Props & {
29
+ mountedFeatures?: readonly CatalogFeatureName[]
30
+ }
31
+
32
+ export const registry: ViewRegistry = {
33
+ @@REGISTRY@@
34
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "module": "@thorprovider/blocks",
3
+ "import": "Breadcrumb",
4
+ "as": "BreadcrumbBlock"
5
+ }
@@ -0,0 +1,4 @@
1
+ Breadcrumb: {
2
+ props: breadcrumbPropsSchema,
3
+ description: 'Migas de pan derivadas de la jerarquía real de categorías.',
4
+ },
@@ -0,0 +1,7 @@
1
+ 'category.breadcrumb': {
2
+ type: 'Breadcrumb',
3
+ props: {
4
+ ...categoryBreadcrumbProps,
5
+ items: { $state: '/breadcrumbItems' },
6
+ },
7
+ },
@@ -0,0 +1,18 @@
1
+ Breadcrumb: ({ props }) => {
2
+ const p = props as {
3
+ items: { label: string; href?: string }[]
4
+ maxItems?: number
5
+ ariaLabel?: string
6
+ backLabel?: string
7
+ className?: string
8
+ }
9
+ return (
10
+ <BreadcrumbBlock
11
+ items={p.items}
12
+ maxItems={p.maxItems}
13
+ ariaLabel={p.ariaLabel}
14
+ backLabel={p.backLabel}
15
+ className={p.className}
16
+ />
17
+ )
18
+ },
@@ -0,0 +1,5 @@
1
+ CategoryFilter2: {
2
+ props: categoryFilterPropsSchema,
3
+ description:
4
+ 'Listado de categoría: una sola presentación controlada CategoryFilter2 con productos, paginación, búsqueda y filtros opcionales.',
5
+ },
@@ -0,0 +1,34 @@
1
+ 'category.filter': {
2
+ type: 'CategoryFilter2',
3
+ props: {
4
+ // User-owned copy first, so a customization can be overridden by the
5
+ // feature config (search placeholder) and the derived state bindings.
6
+ ...categoryFilterProps,
7
+ showSearch: hasSearch,
8
+ showFilters: hasFilters,
9
+ showSort: true,
10
+ showPrice: filtersConfig.showPrice === true,
11
+ ...(hasSearch && searchConfig.placeholder
12
+ ? { searchPlaceholder: searchConfig.placeholder }
13
+ : {}),
14
+ // Composition context for generated navigation (task 4.11): the client
15
+ // registry drops a removed feature's owned orphan parameters on a UI
16
+ // action. Read separately from the block props; never a `CategoryFilter2`
17
+ // prop and never bound to state.
18
+ mountedFeatures,
19
+ products: { $state: '/categoryFilterProducts' },
20
+ pagination: { $state: '/categoryFilterPagination' },
21
+ status: { $state: '/categoryFilterStatus' },
22
+ activeFilters: { $state: '/categoryFilterActiveFilters' },
23
+ searchValue: { $state: '/categoryFilterSearchValue' },
24
+ selectedCategories: { $state: '/categoryFilterSelectedCategories' },
25
+ selectedCollections: { $state: '/categoryFilterSelectedCollections' },
26
+ selectedSort: { $state: '/categoryFilterSelectedSort' },
27
+ selectedPage: { $state: '/categoryFilterSelectedPage' },
28
+ selectedPageSize: { $state: '/categoryFilterSelectedPageSize' },
29
+ sortOptions: { $state: '/categoryFilterSortOptions' },
30
+ categories: { $state: '/categoryFilterCategories' },
31
+ collections: { $state: '/categoryFilterCollections' },
32
+ priceRange: { $state: '/categoryFilterPriceRange' },
33
+ },
34
+ },
@@ -0,0 +1,71 @@
1
+ CategoryFilter2: ({ props, emit }) => {
2
+ const router = useRouter()
3
+ const pathname = usePathname()
4
+ const { set } = useStateStore()
5
+ const p = props as unknown as CategoryFilter2RegistryProps
6
+
7
+ // Minimal `updateURL` seam (task 6.1; task 5.2 verified the criterion and is
8
+ // complete): apply one change to the current browser URL and let the server
9
+ // loader rebuild the listing. The page reset rules live in the pure helper,
10
+ // not here. The installed-feature context is passed so generated navigation
11
+ // drops a removed feature's owned orphan parameters (task 4.11) while never
12
+ // dropping the base listing's route scope, `sort`, `page`, or `pageSize`.
13
+ //
14
+ // History policy (task 5.3): an intentional filter/sort/page/pageSize commit
15
+ // pushes a history entry so reload and back/forward restore the previous
16
+ // query and products; repair and the immediate per-keystroke search replace
17
+ // the current entry (no per-keystroke history explosion). The choice is the
18
+ // pure `catalogHistoryMode` seam, not ad-hoc logic here.
19
+ const apply = (change: CategoryQueryChange) => {
20
+ const search = typeof window === 'undefined' ? '' : window.location.search
21
+ const href = buildCategoryQueryHref(pathname, search, change, p.mountedFeatures)
22
+ if (catalogHistoryMode(change) === 'push') router.push(href)
23
+ else router.replace(href)
24
+ }
25
+
26
+ return (
27
+ <CategoryFilter2Block
28
+ products={p.products}
29
+ categories={p.categories}
30
+ collections={p.collections}
31
+ sortOptions={p.sortOptions}
32
+ pagination={p.pagination}
33
+ priceRange={p.priceRange}
34
+ showSearch={p.showSearch}
35
+ showFilters={p.showFilters}
36
+ showSort={p.showSort}
37
+ showPrice={p.showPrice}
38
+ searchValue={p.searchValue}
39
+ selectedCategories={p.selectedCategories}
40
+ selectedCollections={p.selectedCollections}
41
+ selectedSort={p.selectedSort}
42
+ selectedPage={p.selectedPage}
43
+ selectedPageSize={p.selectedPageSize}
44
+ selectedPriceRange={p.selectedPriceRange}
45
+ status={p.status}
46
+ activeFilters={p.activeFilters}
47
+ filtersTitle={p.filtersTitle}
48
+ searchLabel={p.searchLabel}
49
+ searchPlaceholder={p.searchPlaceholder}
50
+ sortPlaceholder={p.sortPlaceholder}
51
+ productsTitle={p.productsTitle}
52
+ onSearchChange={(text) => apply({ kind: 'search', text })}
53
+ onSortChange={(sort) => apply({ kind: 'sort', sort })}
54
+ onPageChange={(page) => apply({ kind: 'page', page })}
55
+ onPageSizeChange={(size) => apply({ kind: 'pageSize', pageSize: size })}
56
+ onFilterChange={(filters: CategoryFilter2Filters) =>
57
+ apply({ kind: 'filters', categories: filters.categories, collections: filters.collections ?? [] })
58
+ }
59
+ onClearFilters={() => apply({ kind: 'clearFilters' })}
60
+ onRepair={(remove) => apply({ kind: 'repair', remove })}
61
+ onRetry={() => router.refresh()}
62
+ onRemoveFilter={(filter: CategoryFilter2ActiveFilter) =>
63
+ apply({ kind: 'removeFilter', filter })
64
+ }
65
+ onProductClick={(product) => {
66
+ set('/lastClicked', { id: product.id })
67
+ emit('openProduct')
68
+ }}
69
+ />
70
+ )
71
+ },
@@ -0,0 +1,30 @@
1
+ // Thor Commerce view content: category
2
+ // User-owned view content — listing copy for the single controlled
3
+ // `CategoryFilter2`. Reconcile seeds this file once and never rewrites it; the
4
+ // derived view module (./index.tsx) imports it and wires it into the element
5
+ // tree, so your edits here survive `storefront sync` and unit removal. Plain
6
+ // data only: no React and no server imports.
7
+ //
8
+ // The sort OPTIONS are not listed here: they come from the adapter-advertised
9
+ // set (`getCatalogSortKeys`), never from assumed global price sorts.
10
+
11
+ export const filterContent = {
12
+ filtersTitle: 'Filtros',
13
+ searchLabel: 'Buscar',
14
+ searchPlaceholder: 'Buscar productos',
15
+ sortPlaceholder: 'Ordenar por',
16
+ productsTitle: 'Productos',
17
+ emptyTitle: 'Sin resultados',
18
+ emptyDescription: 'Ningún producto coincide con los filtros activos.',
19
+ clearFiltersLabel: 'Limpiar filtros',
20
+ }
21
+
22
+ /**
23
+ * Editorial section instance content, keyed by the section instance id the
24
+ * manifest persists (`views[].sections[].id`, e.g. `category-faq-1`). The derived
25
+ * view module reads `sectionContent[id]`, so adding, reordering, or removing a
26
+ * section never rewrites this file and your copy survives `storefront sync`.
27
+ * Plain data only, matching the section fragment's props; an unset id renders
28
+ * the section's neutral default.
29
+ */
30
+ export const sectionContent: Record<string, Record<string, unknown>> = {}
@@ -0,0 +1,12 @@
1
+ // Thor Commerce view data: category
2
+ // User-owned loader shaping for the category view. Reconcile seeds this file once
3
+ // and never rewrites it; the derived view module (./index.tsx) imports these
4
+ // helpers and calls them from its loader, so edits here survive `storefront sync`
5
+ // and unit removal. Pure functions only: no React and no server imports.
6
+
7
+ export function selectCategoryProducts<T>(
8
+ all: readonly T[],
9
+ scoped: readonly T[] | null,
10
+ ): T[] {
11
+ return scoped ? [...scoped] : [...all]
12
+ }