@thorprovider/storefront 0.3.1 → 0.4.0-mvp.0

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 (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -5,115 +5,50 @@
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 { z } from 'zod'
8
+ import { getDataSource } 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
- import { toFeaturedProduct } from '../../mappers'
13
+ import { benefitsProps, categoriesProps, heroProps } from './props'
14
+ import { selectHeroProducts, toNavCategories } from './data'
15
+ import { toCategoryCard, toFeaturedProduct } from '../../mappers'
16
+ import {
17
+ categoryCardsPropsSchema,
18
+ featurePropsSchema,
19
+ storefrontHeroPropsSchema,
20
+ } from './schema'
14
21
 
22
+ // The element tree is derived wiring; the rendered props come from the
23
+ // user-owned ./props.ts module so they survive sync and unit removal.
15
24
  const elements: Record<string, StorefrontElement> = {
16
25
  'home.hero': {
17
26
  type: 'StorefrontHero',
18
- props: {
19
- badge: 'Bienvenido',
20
- title: '__SITE_NAME__',
21
- titleAccent: 'para cada proyecto',
22
- description: 'Productos seleccionados con precios justos y entrega a tu puerta.',
23
- products: { $state: '/heroProducts' },
24
- primaryCta: { label: 'Comprar ahora' },
25
- secondaryCta: { label: 'Ver categorías' },
26
- },
27
+ props: heroProps,
27
28
  on: { addToCart: { action: 'addToCart' }, openProduct: { action: 'openProduct' } },
28
29
  },
29
30
  'home.benefits': {
30
31
  type: 'Feature',
31
- props: {
32
- badge: '__SITE_NAME__',
33
- title: 'Compra con confianza',
34
- description: 'Beneficios pensados para que tu compra llegue segura y a tiempo.',
35
- items: [
36
- {
37
- id: 'entrega',
38
- title: 'Entrega segura',
39
- description: 'Seguimiento de tu pedido hasta la puerta de tu casa.',
40
- icon: 'truck',
41
- },
42
- {
43
- id: 'pago',
44
- title: 'Pago protegido',
45
- description: 'Compra con confianza, múltiples opciones de pago.',
46
- icon: 'shield-check',
47
- },
48
- {
49
- id: 'soporte',
50
- title: 'Soporte local',
51
- description: 'Atención personalizada en todo el país.',
52
- icon: 'headphones',
53
- },
54
- ],
55
- },
32
+ props: benefitsProps,
56
33
  },
57
34
  'home.categories': {
58
35
  type: 'CategoryCards',
59
- props: {
60
- title: 'Compra por categoría',
61
- description: 'Explora nuestro catálogo completo.',
62
- categories: { $state: '/categories' },
63
- },
36
+ props: categoriesProps,
64
37
  on: { openCategory: { action: 'openCategory' } },
65
38
  },
66
39
  }
67
40
 
68
- const ctaSchema = z.object({ label: z.string(), href: z.string().optional() })
69
-
70
- // Mirrors the StorefrontHero block contract (FeaturedProduct) so a product-shape
71
- // mismatch is a type error at the catalog boundary, not a runtime crash. The
72
- // block requires every field — including `colors` — to be defined at render.
73
- const featuredProductSchema = z.object({
74
- id: z.string(),
75
- name: z.string(),
76
- brand: z.string(),
77
- price: z.number(),
78
- originalPrice: z.number(),
79
- rating: z.number(),
80
- reviewCount: z.number(),
81
- inStock: z.boolean(),
82
- isNew: z.boolean(),
83
- isOnSale: z.boolean(),
84
- image: z.string(),
85
- colors: z.array(z.string()),
86
- })
87
-
88
41
  const catalog = {
89
42
  StorefrontHero: {
90
- props: z.object({
91
- title: z.string(),
92
- description: z.string(),
93
- titleAccent: z.string().optional(),
94
- badge: z.string().optional(),
95
- products: z.array(featuredProductSchema),
96
- primaryCta: ctaSchema.optional(),
97
- secondaryCta: ctaSchema.optional(),
98
- }),
43
+ props: storefrontHeroPropsSchema,
99
44
  description: 'Landing hero.',
100
45
  },
101
46
  Feature: {
102
- props: z.object({
103
- items: z.array(z.record(z.string(), z.unknown())),
104
- badge: z.string().optional(),
105
- title: z.string().optional(),
106
- description: z.string().optional(),
107
- }),
47
+ props: featurePropsSchema,
108
48
  description: 'Benefit highlights.',
109
49
  },
110
50
  CategoryCards: {
111
- props: z.object({
112
- title: z.string().optional(),
113
- description: z.string().optional(),
114
- categories: z.array(z.record(z.string(), z.unknown())),
115
- cta: ctaSchema.optional(),
116
- }),
51
+ props: categoryCardsPropsSchema,
117
52
  description: 'Category grid.',
118
53
  },
119
54
  }
@@ -135,8 +70,27 @@ export const home: ViewModule = {
135
70
  },
136
71
  loader: {
137
72
  home: async () => {
138
- const { products, categories } = await getCatalog()
139
- return { heroProducts: products.slice(0, 4).map(toFeaturedProduct), categories }
73
+ try {
74
+ const { products, categories, source } = await getCatalog()
75
+ return {
76
+ heroProducts: selectHeroProducts(products).map(toFeaturedProduct),
77
+ categories: categories.map(toCategoryCard),
78
+ navCategories: toNavCategories(categories),
79
+ dataSource: source,
80
+ dataError: null,
81
+ }
82
+ } catch (error) {
83
+ // A real-mode failure is surfaced as a visible error, never as fictional
84
+ // data. Mock mode is labelled by `dataSource`.
85
+ return {
86
+ heroProducts: [],
87
+ categories: [],
88
+ navCategories: [],
89
+ dataSource: getDataSource(),
90
+ dataError:
91
+ error instanceof Error ? error.message : 'No se pudo cargar el catálogo.',
92
+ }
93
+ }
140
94
  },
141
95
  },
142
96
  }
@@ -0,0 +1,22 @@
1
+ // Thor Commerce view props: home
2
+ // User-owned element props for the home view. Reconcile seeds this file once and
3
+ // never rewrites it; the derived view module (./index.tsx) imports these objects
4
+ // and binds them into the element tree, so edits here survive `storefront sync`
5
+ // and unit removal. Plain data only: no React and no server imports.
6
+
7
+ import { benefitsContent, categoriesContent, heroContent, heroImage } from './content'
8
+
9
+ export const heroProps = {
10
+ ...heroContent,
11
+ products: { $state: '/heroProducts' },
12
+ heroImage,
13
+ }
14
+
15
+ export const benefitsProps = {
16
+ ...benefitsContent,
17
+ }
18
+
19
+ export const categoriesProps = {
20
+ ...categoriesContent,
21
+ categories: { $state: '/categories' },
22
+ }
@@ -5,31 +5,43 @@
5
5
  // @json-render/react and components from @thorprovider/blocks — both must stay
6
6
  // on the client side of the RSC graph. Kept separate from index.tsx so the view
7
7
  // spec (elements/catalog/route/loader) stays a pure Server Component module.
8
+ //
9
+ // Props arrive as untyped spec data: every entry parses them through the view's
10
+ // schema (./schema.ts) before binding, so a contract mismatch fails loudly at
11
+ // the boundary instead of rendering undefined fields or literal icon tags.
8
12
 
9
13
  import {
10
14
  Feature1 as FeatureBlock,
11
15
  ProductCategory2 as CategoryCardsBlock,
12
16
  StorefrontHero1 as StorefrontHeroBlock,
13
17
  } from '@thorprovider/blocks'
14
- import type { FeaturedProduct } from '@thorprovider/blocks'
15
18
  import { useStateStore } from '@json-render/react'
19
+ import { resolveIcon } from '../../icon-map'
16
20
  import type { ViewRegistry } from '../types'
21
+ import {
22
+ categoryCardsPropsSchema,
23
+ featurePropsSchema,
24
+ storefrontHeroPropsSchema,
25
+ } from './schema'
17
26
 
18
27
  export const registry: ViewRegistry = {
19
28
  StorefrontHero: ({ props, emit }) => {
20
29
  const { set } = useStateStore()
30
+ const hero = storefrontHeroPropsSchema.parse(props)
21
31
  return (
22
32
  <StorefrontHeroBlock
23
- title={props.title as string}
24
- description={props.description as string}
25
- products={props.products as FeaturedProduct[]}
26
- titleAccent={props.titleAccent as string}
27
- badge={props.badge as string}
28
- primaryCta={props.primaryCta as never}
29
- secondaryCta={props.secondaryCta as never}
33
+ title={hero.title}
34
+ description={hero.description}
35
+ products={hero.products}
36
+ titleAccent={hero.titleAccent}
37
+ badge={hero.badge}
38
+ primaryCta={hero.primaryCta}
39
+ secondaryCta={hero.secondaryCta}
40
+ heroImage={hero.heroImage}
30
41
  onAddToCart={(product) => {
42
+ // No first-variant shortcut: open the detail so a variant is chosen.
31
43
  set('/lastClicked', product)
32
- emit('addToCart')
44
+ emit('openProduct')
33
45
  }}
34
46
  onBuyNow={(product) => {
35
47
  set('/lastClicked', product)
@@ -39,24 +51,28 @@ export const registry: ViewRegistry = {
39
51
  />
40
52
  )
41
53
  },
42
- Feature: ({ props }) => (
43
- <FeatureBlock
44
- items={props.items as never}
45
- badge={props.badge as string}
46
- title={props.title as string}
47
- description={props.description as string}
48
- />
49
- ),
54
+ Feature: ({ props }) => {
55
+ const feature = featurePropsSchema.parse(props)
56
+ return (
57
+ <FeatureBlock
58
+ items={feature.items.map((item) => ({ ...item, icon: resolveIcon(item.icon) }))}
59
+ badge={feature.badge}
60
+ title={feature.title}
61
+ description={feature.description}
62
+ />
63
+ )
64
+ },
50
65
  CategoryCards: ({ props, emit }) => {
51
66
  const { set } = useStateStore()
67
+ const cards = categoryCardsPropsSchema.parse(props)
52
68
  return (
53
69
  <CategoryCardsBlock
54
- title={props.title as string}
55
- description={props.description as string}
56
- categories={props.categories as never}
57
- cta={props.cta as never}
70
+ title={cards.title}
71
+ description={cards.description}
72
+ categories={cards.categories}
73
+ cta={cards.cta}
58
74
  onBrowse={(category) => {
59
- set('/lastCategory', (category.handle as string) ?? String(category.id))
75
+ set('/lastCategory', category.handle ?? String(category.id))
60
76
  emit('openCategory')
61
77
  }}
62
78
  />
@@ -0,0 +1,93 @@
1
+ // Thor Commerce view schema fragment: home
2
+ // Zod mirrors of the block contracts for this view — the single source for the
3
+ // catalog entries 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.
7
+
8
+ import { z } from 'zod'
9
+
10
+ export const ctaSchema = z.object({
11
+ label: z.string(),
12
+ href: z.string().optional(),
13
+ })
14
+
15
+ // Mirrors the StorefrontHero block contract (FeaturedProduct): the block
16
+ // requires every field — including `colors` — to be defined at render.
17
+ export const featuredProductSchema = z.object({
18
+ id: z.string(),
19
+ name: z.string(),
20
+ brand: z.string(),
21
+ price: z.number(),
22
+ originalPrice: z.number(),
23
+ rating: z.number(),
24
+ reviewCount: z.number(),
25
+ inStock: z.boolean(),
26
+ isNew: z.boolean(),
27
+ isOnSale: z.boolean(),
28
+ image: z.string(),
29
+ colors: z.array(z.string()),
30
+ })
31
+
32
+ // Mirrors the Feature1 block contract; `icon` is a NAME here and only becomes
33
+ // a component when the registry resolves it through the shared icon map.
34
+ export const featureItemSchema = z.object({
35
+ id: z.string(),
36
+ title: z.string(),
37
+ description: z.string(),
38
+ icon: z.string(),
39
+ })
40
+
41
+ // Mirrors the ProductCategory2 block contract (ProductCategory2Category).
42
+ // `itemCount` is optional: when the backend count is unknown the projection
43
+ // omits it (the block renders no count) rather than fabricating a `0`. A real
44
+ // `0` is preserved.
45
+ export const categoryCardSchema = z.object({
46
+ id: z.string(),
47
+ handle: z.string().optional(),
48
+ name: z.string(),
49
+ description: z.string(),
50
+ itemCount: z.number().optional(),
51
+ image: z.string(),
52
+ trending: z.boolean(),
53
+ })
54
+
55
+ // Optional editorial hero image (mirrors the StorefrontHero block contract).
56
+ // Rendered as a native `<img>` only when `src` is a non-empty string; omitted
57
+ // entirely when absent, so a blank `src` is never emitted.
58
+ export const heroImageSchema = z.object({
59
+ src: z.string(),
60
+ alt: z.string(),
61
+ })
62
+
63
+ export const storefrontHeroPropsSchema = z.object({
64
+ title: z.string(),
65
+ description: z.string(),
66
+ titleAccent: z.string().optional(),
67
+ badge: z.string().optional(),
68
+ products: z.array(featuredProductSchema),
69
+ primaryCta: ctaSchema.optional(),
70
+ secondaryCta: ctaSchema.optional(),
71
+ heroImage: heroImageSchema.optional(),
72
+ })
73
+
74
+ export const featurePropsSchema = z.object({
75
+ items: z.array(featureItemSchema),
76
+ badge: z.string().optional(),
77
+ title: z.string().optional(),
78
+ description: z.string().optional(),
79
+ })
80
+
81
+ export const categoryCardsPropsSchema = z.object({
82
+ title: z.string().optional(),
83
+ description: z.string().optional(),
84
+ categories: z.array(categoryCardSchema),
85
+ cta: ctaSchema.optional(),
86
+ })
87
+
88
+ export type FeaturedProduct = z.infer<typeof featuredProductSchema>
89
+ export type FeatureItem = z.infer<typeof featureItemSchema>
90
+ export type CategoryCard = z.infer<typeof categoryCardSchema>
91
+ export type StorefrontHeroProps = z.infer<typeof storefrontHeroPropsSchema>
92
+ export type FeatureProps = z.infer<typeof featurePropsSchema>
93
+ export type CategoryCardsProps = z.infer<typeof categoryCardsPropsSchema>
@@ -0,0 +1,25 @@
1
+ // Thor Commerce view content: product
2
+ // User-owned view content — the labels rendered by the product overview. No
3
+ // reviews are seeded: reviews are rendered only when the backend supplies real
4
+ // ones, so this file must never carry fabricated review data. Reconcile seeds
5
+ // this file once and never rewrites it; the derived view module (./index.tsx)
6
+ // imports it and wires it into the element tree, so your edits here survive
7
+ // `storefront sync` and unit removal. Plain data only: no React and no server
8
+ // imports.
9
+
10
+ export const productLabels = {
11
+ sizesLabel: 'Variantes',
12
+ variantLabel: 'Variante',
13
+ reviewsLabel: 'Reseñas',
14
+ addToCartLabel: 'Agregar al carrito',
15
+ outOfStockLabel: 'Agotado',
16
+ unavailablePriceLabel: 'Precio no disponible',
17
+ selectVariantLabel: 'Selecciona una variante',
18
+ quantityLabel: 'Cantidad',
19
+ }
20
+
21
+ // Copy for the reviews region. It renders only when the backend supplies real
22
+ // reviews (`/hasProductReviews`); the list itself is never seeded here.
23
+ export const reviewsCopy = {
24
+ pageTitle: 'Opiniones de clientes',
25
+ }
@@ -0,0 +1,48 @@
1
+ // Thor Commerce view data: product
2
+ // User-owned loader shaping for the product 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
+ import type { CatalogProductDetail } from '@thorprovider/runtime/commerce'
8
+ import type { ProductDetailProduct } from '@thorprovider/blocks'
9
+
10
+ /**
11
+ * Map the runtime's canonical product detail (real variants, options,
12
+ * availability, Money) to the ProductOverview1 block contract.
13
+ *
14
+ * Price availability is preserved, not collapsed: a variant whose backend price
15
+ * is unresolved carries `price: null` (the block treats that as unavailable and
16
+ * disables purchase), while a backend-resolved valid zero stays `"0"`.
17
+ */
18
+ export function buildSelectedProduct(
19
+ detail: CatalogProductDetail | null | undefined,
20
+ ): ProductDetailProduct | null {
21
+ if (!detail) return null
22
+ return {
23
+ id: detail.id,
24
+ handle: detail.handle,
25
+ title: detail.title,
26
+ description: detail.description,
27
+ options: detail.options,
28
+ variants: detail.variants.map((variant) => ({
29
+ id: variant.id,
30
+ title: variant.title,
31
+ sku: variant.sku,
32
+ availableForSale: variant.availableForSale,
33
+ selectedOptions: variant.options,
34
+ price: variant.price.status === 'available' ? variant.price.money : null,
35
+ image: variant.image,
36
+ })),
37
+ images: detail.images,
38
+ featuredImage: detail.featuredImage,
39
+ }
40
+ }
41
+
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.
45
+ */
46
+ export function initialSelectedVariantId(): string | null {
47
+ return null
48
+ }
@@ -0,0 +1,104 @@
1
+ // Thor Commerce view module: product
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 -> "product".
6
+
7
+ import { notFound } from 'next/navigation'
8
+ import { loadProductDetail } from '../../commerce'
9
+ import { getDataSource } from '../../store-context'
10
+ import { pageRoot } from '../../spec/page-root'
11
+ import type { StorefrontElement } from '../../spec/types'
12
+ import type { ViewModule } from '../types'
13
+ import { registry } from './registry'
14
+ import { productOverviewProps, productReviewsProps } from './props'
15
+ import { buildSelectedProduct, initialSelectedVariantId } from './data'
16
+ import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
17
+
18
+ // The element tree is derived wiring; the rendered props come from the
19
+ // user-owned ./props.ts module so they survive sync and unit removal.
20
+ const elements: Record<string, StorefrontElement> = {
21
+ 'product.overview': {
22
+ type: 'ProductOverview',
23
+ visible: { $state: '/selectedProduct' },
24
+ props: productOverviewProps,
25
+ on: { addToCart: { action: 'addToCart' } },
26
+ },
27
+ // Rendered only when the backend supplies real reviews; the list is never
28
+ // seeded, so an unsupplied review is not shown and no placeholder is invented.
29
+ 'product.reviews': {
30
+ type: 'Reviews',
31
+ visible: { $state: '/hasProductReviews' },
32
+ props: productReviewsProps,
33
+ on: {
34
+ submitReview: { action: 'submitReview' },
35
+ toggleReviewLike: { action: 'toggleReviewLike' },
36
+ },
37
+ },
38
+ }
39
+
40
+ const catalog = {
41
+ ProductOverview: {
42
+ props: productOverviewPropsSchema,
43
+ description: 'Detalle de producto: galería, precio, variantes y añadir al carrito.',
44
+ },
45
+ Reviews: {
46
+ props: reviewsPropsSchema,
47
+ description: 'Opiniones de clientes con resumen de valoración.',
48
+ },
49
+ }
50
+
51
+ export const product: ViewModule = {
52
+ name: 'product',
53
+ elements,
54
+ catalog,
55
+ registry,
56
+ route: {
57
+ path: '/productos/[id]',
58
+ loaderName: 'product',
59
+ layout: 'default',
60
+ metadata: {
61
+ title: 'Producto',
62
+ description: 'Detalles del producto seleccionado.',
63
+ },
64
+ entry: pageRoot('product', ['product.overview', 'product.reviews'], elements),
65
+ },
66
+ loader: {
67
+ product: async (params) => {
68
+ const handle = Array.isArray(params.id) ? params.id[0] : params.id
69
+ const dataSource = getDataSource()
70
+
71
+ let result: Awaited<ReturnType<typeof loadProductDetail>>
72
+ try {
73
+ result = await loadProductDetail(String(handle))
74
+ } catch (error) {
75
+ // A real data-source failure is surfaced visibly (never demo data).
76
+ return {
77
+ selectedProduct: null,
78
+ selectedVariantId: null,
79
+ productQuantity: 1,
80
+ productReviews: [],
81
+ hasProductReviews: false,
82
+ dataSource,
83
+ dataError:
84
+ error instanceof Error ? error.message : 'No se pudo cargar el producto.',
85
+ }
86
+ }
87
+
88
+ // An unknown handle is a real 404 (the catch-all page renders not-found);
89
+ // it is never a demo product.
90
+ if (!result.detail) notFound()
91
+
92
+ return {
93
+ selectedProduct: buildSelectedProduct(result.detail),
94
+ // No first-variant shortcut: the shopper must select explicitly.
95
+ selectedVariantId: initialSelectedVariantId(),
96
+ productQuantity: 1,
97
+ productReviews: [],
98
+ hasProductReviews: false,
99
+ dataSource: result.source,
100
+ dataError: null,
101
+ }
102
+ },
103
+ },
104
+ }
@@ -0,0 +1,20 @@
1
+ // Thor Commerce view props: product
2
+ // User-owned element props for the product view. Reconcile seeds this file once
3
+ // and never rewrites it; the derived view module (./index.tsx) imports these
4
+ // objects and binds them into the element tree, so edits here survive
5
+ // `storefront sync` and unit removal. Plain data only: no React and no server
6
+ // imports.
7
+
8
+ import { productLabels, reviewsCopy } from './content'
9
+
10
+ export const productOverviewProps = {
11
+ ...productLabels,
12
+ product: { $state: '/selectedProduct' },
13
+ selectedVariantId: { $state: '/selectedVariantId' },
14
+ quantity: { $state: '/productQuantity' },
15
+ }
16
+
17
+ export const productReviewsProps = {
18
+ ...reviewsCopy,
19
+ reviews: { $state: '/productReviews' },
20
+ }
@@ -0,0 +1,78 @@
1
+ 'use client'
2
+
3
+ // Thor Commerce view registry fragment: product
4
+ // Client boundary for the product view. Imports value bindings from
5
+ // @json-render/react and components from @thorprovider/blocks — both must stay
6
+ // on the client side of the RSC graph. Kept separate from index.tsx so the view
7
+ // spec (elements/catalog/route/loader) stays a pure Server Component module.
8
+
9
+ import {
10
+ ProductOverview1 as ProductOverviewBlock,
11
+ ReviewRating1 as ReviewsBlock,
12
+ } from '@thorprovider/blocks'
13
+ import { isAvailableMoney } from '@thorprovider/blocks/contracts'
14
+ import { useStateStore } from '@json-render/react'
15
+ import type { ViewRegistry } from '../types'
16
+ import { productOverviewPropsSchema, reviewsPropsSchema } from './schema'
17
+
18
+ export const registry: ViewRegistry = {
19
+ ProductOverview: ({ props, emit }) => {
20
+ const { set } = useStateStore()
21
+ const parsed = productOverviewPropsSchema.parse(props)
22
+ return (
23
+ <ProductOverviewBlock
24
+ product={parsed.product}
25
+ selectedVariantId={parsed.selectedVariantId ?? null}
26
+ quantity={parsed.quantity}
27
+ sizesLabel={parsed.sizesLabel}
28
+ variantLabel={parsed.variantLabel}
29
+ reviewsLabel={parsed.reviewsLabel}
30
+ addToCartLabel={parsed.addToCartLabel}
31
+ outOfStockLabel={parsed.outOfStockLabel}
32
+ unavailablePriceLabel={parsed.unavailablePriceLabel}
33
+ selectVariantLabel={parsed.selectVariantLabel}
34
+ quantityLabel={parsed.quantityLabel}
35
+ onSelectVariant={(variantId) => {
36
+ set('/selectedVariantId', variantId)
37
+ }}
38
+ onQuantityChange={(quantity) => {
39
+ set('/productQuantity', quantity)
40
+ }}
41
+ onAddToCart={(variantId, quantity) => {
42
+ // Preserve the selected backend variant id and quantity the block
43
+ // emits — the handler must never drop them or substitute the SKU.
44
+ const variant = parsed.product.variants.find((candidate) => candidate.id === variantId)
45
+ const price = isAvailableMoney(variant?.price) ? Number(variant.price.amount) : 0
46
+ set('/lastAddToCart', {
47
+ variantId,
48
+ quantity,
49
+ name: parsed.product.title,
50
+ variant: variant?.title ?? '',
51
+ price,
52
+ image: variant?.image?.url ?? parsed.product.featuredImage?.url,
53
+ })
54
+ emit('addToCart')
55
+ }}
56
+ onToggleWishlist={() => {}}
57
+ />
58
+ )
59
+ },
60
+ Reviews: ({ props, emit }) => {
61
+ const parsed = reviewsPropsSchema.parse(props)
62
+ return (
63
+ <ReviewsBlock
64
+ pageTitle={parsed.pageTitle}
65
+ pageDescription={parsed.pageDescription}
66
+ reviews={parsed.reviews}
67
+ form={parsed.form}
68
+ reviewsPerPage={parsed.reviewsPerPage}
69
+ onToggleReviewLike={() => {
70
+ emit('toggleReviewLike')
71
+ }}
72
+ onSubmitReview={() => {
73
+ emit('submitReview')
74
+ }}
75
+ />
76
+ )
77
+ },
78
+ }