@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
@@ -22,7 +22,7 @@ import { useStateStore } from '@json-render/react'
22
22
  import type { Customer } from '@thorprovider/types'
23
23
  import {
24
24
  loadAccountAction,
25
- loadOrdersAction,
25
+ loadOrdersPageAction,
26
26
  loginAction,
27
27
  logoutAction,
28
28
  registerAction,
@@ -39,6 +39,9 @@ import {
39
39
  type AuthResult = { ok: true; customer: Customer | null } | { ok: false; error: string }
40
40
  type SetState = (path: string, value: unknown) => void
41
41
 
42
+ /** Bounded page size for the account order-history window. */
43
+ const ORDERS_PAGE_SIZE = 10
44
+
42
45
  // Apply a settled auth result truthfully: a failure sets an error and NEVER a
43
46
  // logged-in state; a success stores the customer (or null) and nothing else.
44
47
  function applyAuthResult(set: SetState, result: AuthResult): void {
@@ -100,9 +103,11 @@ interface BlockOrder {
100
103
  items: BlockOrderItem[]
101
104
  }
102
105
 
103
- // Map a backend-sourced account order row to the block's order shape. The order
104
- // number is the list key; an absent variant/image becomes '' because the block
105
- // contract is a string — never an invented value.
106
+ // Map a backend-sourced account order row to the block's order shape. The block
107
+ // has a single `id` slot which it also renders ("Pedido {id}"), so it carries the
108
+ // display number; the registry keeps a number→backend-id map so selection is
109
+ // keyed by the REAL backend order id. An absent variant/image becomes '' because
110
+ // the block contract is a string — never an invented value.
106
111
  function toBlockOrder(order: AccountOrderView): BlockOrder {
107
112
  return {
108
113
  id: order.number,
@@ -295,26 +300,30 @@ export const registry: ViewRegistry = {
295
300
  )
296
301
  },
297
302
  /**
298
- * Signed-in order history. The load runs once from the cookie-authorized
299
- * Server Action; loading/empty/error are distinct and a failure never renders
300
- * a fabricated order. `onViewDetails` records the selected order in state and
301
- * the detail is assembled below from the already-fetched row — no second
302
- * backend request. No token is read, stored, or rendered here.
303
+ * Signed-in order history. The first page loads once from the cookie-authorized
304
+ * Server Action; later pages load on demand, each scoped to the authenticated
305
+ * customer's channel. The backend's real `count` drives pagination, so a page
306
+ * length is never presented as the total. Selection is keyed by the REAL
307
+ * backend order id (via a number→id map), so an opened detail stays stable
308
+ * across paging and reload. No token is read, stored, or rendered here.
303
309
  */
304
310
  AccountOrders: ({ props }) => {
305
311
  const { state, set } = useStateStore()
306
312
  const parsed = accountOrdersPropsSchema.parse(props)
307
- const loaded = useRef(false)
308
- useEffect(() => {
309
- if (loaded.current) return
310
- loaded.current = true
313
+ const authenticated = Boolean(state.accountAuthenticated)
314
+ const loadedForAuth = useRef(false)
315
+
316
+ const loadPage = (page: number) => {
311
317
  set('/accountOrdersPending', true)
312
318
  set('/accountOrdersError', null)
313
- void loadOrdersAction()
319
+ void loadOrdersPageAction({ page, pageSize: ORDERS_PAGE_SIZE })
314
320
  .then((result) => {
315
321
  set('/accountOrdersPending', false)
316
322
  if (result.ok) {
317
323
  set('/accountOrders', result.orders)
324
+ set('/accountOrdersCount', result.count)
325
+ set('/accountOrdersPage', page)
326
+ set('/accountOrdersPageSize', result.limit)
318
327
  set('/accountOrdersError', null)
319
328
  return
320
329
  }
@@ -328,19 +337,40 @@ export const registry: ViewRegistry = {
328
337
  set('/accountOrdersPending', false)
329
338
  set('/accountOrdersError', parsed.errorLabel ?? 'No se pudieron cargar tus pedidos.')
330
339
  })
340
+ }
341
+
342
+ useEffect(() => {
343
+ // Load the first page when the customer becomes authenticated, and reload
344
+ // after a re-login, so the history appears without a manual reload. A
345
+ // guest load is never attempted; the flag resets when the session ends.
346
+ if (!authenticated) {
347
+ loadedForAuth.current = false
348
+ return
349
+ }
350
+ if (loadedForAuth.current) return
351
+ loadedForAuth.current = true
352
+ loadPage(1)
331
353
  // eslint-disable-next-line react-hooks/exhaustive-deps
332
- }, [])
354
+ }, [authenticated])
333
355
 
334
356
  const pending = Boolean(state.accountOrdersPending)
335
357
  const error = typeof state.accountOrdersError === 'string' ? state.accountOrdersError : null
336
358
  const orders = Array.isArray(state.accountOrders)
337
359
  ? (state.accountOrders as AccountOrderView[])
338
360
  : []
361
+ const count =
362
+ typeof state.accountOrdersCount === 'number' ? state.accountOrdersCount : orders.length
363
+ const page = typeof state.accountOrdersPage === 'number' ? state.accountOrdersPage : 1
364
+ const pageSize =
365
+ typeof state.accountOrdersPageSize === 'number' && state.accountOrdersPageSize > 0
366
+ ? state.accountOrdersPageSize
367
+ : ORDERS_PAGE_SIZE
339
368
  const selectedId =
340
369
  typeof state.accountSelectedOrderId === 'string' ? state.accountSelectedOrderId : null
341
- const selected = selectedId
342
- ? orders.find((order) => order.number === selectedId) ?? null
343
- : null
370
+ // Selection is keyed by the REAL backend order id, never the display number.
371
+ const selected = selectedId ? orders.find((order) => order.id === selectedId) ?? null : null
372
+ // The block row carries the display number; resolve it back to the backend id.
373
+ const backendIdByBlockId = new Map(orders.map((order) => [order.number, order.id]))
344
374
 
345
375
  if (pending) {
346
376
  return (
@@ -363,14 +393,45 @@ export const registry: ViewRegistry = {
363
393
  </p>
364
394
  )
365
395
  }
396
+ const totalPages = Math.max(1, Math.ceil(count / pageSize))
366
397
  return (
367
398
  <>
368
399
  <OrderHistory2
369
400
  orders={orders.map(toBlockOrder)}
370
401
  titleLabel={parsed.titleLabel}
371
402
  descriptionLabel={parsed.descriptionLabel}
372
- onViewDetails={(order) => set('/accountSelectedOrderId', order.id)}
403
+ // The block renders "Total de pedidos: {n}" from the PAGE length; pass a
404
+ // label without `{n}` so a page length is never shown as the total.
405
+ totalOrdersLabel={parsed.totalOrdersLabel ?? 'Pedidos'}
406
+ onViewDetails={(order) =>
407
+ set('/accountSelectedOrderId', backendIdByBlockId.get(order.id) ?? null)
408
+ }
373
409
  />
410
+ {count > pageSize ? (
411
+ <div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4 px-4 pb-6 sm:px-6 lg:px-8">
412
+ <p className="text-muted-foreground text-sm" role="status" aria-live="polite">
413
+ {`${count} pedidos · página ${page} de ${totalPages}`}
414
+ </p>
415
+ <div className="flex gap-2">
416
+ <Button
417
+ variant="outline"
418
+ size="sm"
419
+ disabled={pending || page <= 1}
420
+ onClick={() => loadPage(page - 1)}
421
+ >
422
+ Anterior
423
+ </Button>
424
+ <Button
425
+ variant="outline"
426
+ size="sm"
427
+ disabled={pending || page >= totalPages}
428
+ onClick={() => loadPage(page + 1)}
429
+ >
430
+ Siguiente
431
+ </Button>
432
+ </div>
433
+ </div>
434
+ ) : null}
374
435
  {selected ? <OrderDetail order={selected} /> : null}
375
436
  </>
376
437
  )
@@ -38,6 +38,7 @@ export const accountRegisterPropsSchema = z.object({
38
38
  export const accountOrdersPropsSchema = z.object({
39
39
  titleLabel: z.string().optional(),
40
40
  descriptionLabel: z.string().optional(),
41
+ totalOrdersLabel: z.string().optional(),
41
42
  emptyLabel: z.string().optional(),
42
43
  errorLabel: z.string().optional(),
43
44
  loadingLabel: z.string().optional(),
@@ -0,0 +1,11 @@
1
+ // Thor Commerce view content: cart (user-owned)
2
+ // Merchant-editable copy for the standalone cart review page. Seeded once by the
3
+ // scaffolder and never overwritten on sync.
4
+
5
+ export const cartContent = {
6
+ titleLabel: 'Tu carrito',
7
+ checkoutLabel: 'Finalizar compra',
8
+ continueShoppingLabel: 'Seguir comprando',
9
+ emptyLabel: 'Tu carrito está vacío',
10
+ readErrorLabel: 'No se pudo cargar tu carrito.',
11
+ }
@@ -0,0 +1,23 @@
1
+ // Thor Commerce view data: cart (user-owned)
2
+ // Pure mapping from the shared host cart state to the ShoppingCart4 block props.
3
+ // No React and no server imports so it can be imported from either boundary.
4
+
5
+ import type { ShoppingCart4Item } from '@thorprovider/blocks'
6
+ import type { CartLineState } from '../../state'
7
+
8
+ /**
9
+ * Maps backend cart lines to the cart block's item shape. `price` stays 0 (the
10
+ * legacy numeric field) because the block renders the canonical `lineTotal`
11
+ * Money; an absent image is left undefined rather than serialized as "".
12
+ */
13
+ export const toCartViewItems = (cart: CartLineState[]): ShoppingCart4Item[] =>
14
+ cart.map(line => ({
15
+ id: line.id,
16
+ name: line.name,
17
+ price: 0,
18
+ quantity: line.quantity,
19
+ image: line.image,
20
+ variant: line.variant,
21
+ lineTotal: line.lineTotal,
22
+ unitPrice: line.unitPrice,
23
+ }))
@@ -0,0 +1,53 @@
1
+ // Thor Commerce view: cart
2
+ // The standalone cart review route. Its data comes from the shared host cart
3
+ // state injected on every route (readCartStateForRender), so the loader only
4
+ // reports the data source — no dedicated cart fetch happens here.
5
+
6
+ import { getDataSource } from '../../store-context'
7
+ import { pageRoot } from '../../spec/page-root'
8
+ import type { StorefrontElement } from '../../spec/types'
9
+ import type { ViewModule } from '../types'
10
+ import { cartViewProps } from './props'
11
+ import { registry } from './registry'
12
+ import { cartViewPropsSchema } from './schema'
13
+
14
+ const elements: Record<string, StorefrontElement> = {
15
+ 'cart.view': {
16
+ type: 'CartView',
17
+ props: cartViewProps,
18
+ on: {
19
+ refreshCart: { action: 'refreshCart' },
20
+ updateQuantity: { action: 'updateQuantity' },
21
+ removeFromCart: { action: 'removeFromCart' },
22
+ goToCheckout: { action: 'goToCheckout' },
23
+ goHome: { action: 'goHome' },
24
+ },
25
+ },
26
+ }
27
+
28
+ const catalog = {
29
+ CartView: {
30
+ props: cartViewPropsSchema,
31
+ description: 'Standalone cart review page with backend-authoritative totals.',
32
+ },
33
+ }
34
+
35
+ export const cart: ViewModule = {
36
+ name: 'cart',
37
+ elements,
38
+ catalog,
39
+ registry,
40
+ route: {
41
+ path: '/cart',
42
+ loaderName: 'cart',
43
+ layout: 'default',
44
+ metadata: {
45
+ title: 'Carrito',
46
+ description: 'Revisa los artículos de tu carrito antes de finalizar la compra.',
47
+ },
48
+ entry: pageRoot('cart', ['cart.view'], elements),
49
+ },
50
+ loader: {
51
+ cart: async () => ({ dataSource: getDataSource(), dataError: null }),
52
+ },
53
+ }
@@ -0,0 +1,12 @@
1
+ // Thor Commerce view props: cart (user-owned)
2
+ // Element props for the cart review view. The cart lines and backend-authoritative
3
+ // totals are read from the shared host state (injected for every route), so this
4
+ // view needs no dedicated data fetch.
5
+
6
+ import { cartContent } from './content'
7
+
8
+ export const cartViewProps = {
9
+ ...cartContent,
10
+ items: { $state: '/cart' },
11
+ totals: { $state: '/cartTotals' },
12
+ }
@@ -0,0 +1,81 @@
1
+ 'use client'
2
+
3
+ // Thor Commerce view registry: cart
4
+ // Renders the standalone cart review page (page-mode ShoppingCart4) from the
5
+ // shared host cart state. Quantity updates use the ABSOLUTE line quantity keyed
6
+ // by backend line id; removal uses the same line id. Checkout is only offered
7
+ // when the checkout capability is installed and the cart is ready.
8
+
9
+ import { Button, ShoppingCart4 } from '@thorprovider/blocks'
10
+ import { isAvailableMoney } from '@thorprovider/blocks/contracts'
11
+ import { useStateStore } from '@json-render/react'
12
+ import { checkoutAvailable } from '../../composition'
13
+ import type { ViewRegistry } from '../types'
14
+ import { toCartViewItems } from './data'
15
+ import { cartViewPropsSchema } from './schema'
16
+
17
+ export const registry: ViewRegistry = {
18
+ CartView: ({ props, emit }) => {
19
+ const { state, set } = useStateStore()
20
+ const parsed = cartViewPropsSchema.parse(props)
21
+ const cart = parsed.items
22
+ const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
23
+ const readError = typeof state.cartReadError === 'string' ? state.cartReadError : undefined
24
+ const isPending = state.cartPending === true
25
+ const errorLabel = cartError ?? readError
26
+ const totalsState = isPending ? 'pending' : errorLabel ? 'error' : 'idle'
27
+ // Checkout is offered only when the required backend amounts are present and
28
+ // valid: a missing/malformed subtotal, total, or line amount blocks it, while
29
+ // an undetermined shipping amount does not (AC-CART-4.4 / UC-CART-7).
30
+ const amountsValid =
31
+ cart.every((line) => isAvailableMoney(line.lineTotal)) &&
32
+ parsed.totals != null &&
33
+ isAvailableMoney(parsed.totals.subtotal) &&
34
+ isAvailableMoney(parsed.totals.total)
35
+
36
+ // A read failure with no confirmed lines is not an empty cart: surface it as
37
+ // a recoverable error rather than the empty state.
38
+ if (readError && cart.length === 0) {
39
+ return (
40
+ <section className='mx-auto max-w-3xl px-4 py-8'>
41
+ <div role='alert' className='flex flex-col items-start gap-3'>
42
+ <p className='text-destructive text-sm'>{parsed.readErrorLabel ?? readError}</p>
43
+ <Button variant='outline' size='sm' onClick={() => emit('refreshCart')}>
44
+ Reintentar
45
+ </Button>
46
+ </div>
47
+ </section>
48
+ )
49
+ }
50
+
51
+ return (
52
+ <ShoppingCart4
53
+ presentation='page'
54
+ items={toCartViewItems(cart)}
55
+ promotions={[]}
56
+ totals={parsed.totals ?? undefined}
57
+ totalsState={totalsState}
58
+ totalsErrorLabel={errorLabel}
59
+ titleLabel={parsed.titleLabel}
60
+ checkoutLabel={parsed.checkoutLabel}
61
+ continueShoppingLabel={parsed.continueShoppingLabel}
62
+ emptyLabel={parsed.emptyLabel}
63
+ onRetryTotals={() => emit('refreshCart')}
64
+ onQuantityChange={(id, quantity) => {
65
+ const line = cart.find(item => item.id === id)
66
+ if (!line) return
67
+ set('/qtyUpdate', { id, merchandiseId: line.merchandiseId, quantity })
68
+ emit('updateQuantity')
69
+ }}
70
+ onRemove={id => {
71
+ set('/lastRemoved', id)
72
+ emit('removeFromCart')
73
+ }}
74
+ onCheckout={() => emit('goToCheckout')}
75
+ onContinueShopping={() => emit('goHome')}
76
+ checkoutUnavailable={!checkoutAvailable}
77
+ checkoutDisabled={isPending || Boolean(errorLabel) || cart.length === 0 || !amountsValid}
78
+ />
79
+ )
80
+ },
81
+ }
@@ -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>
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "view": "category",
3
3
  "slots": [
4
+ { "id": "category.breadcrumb", "slot": "breadcrumb", "default": "Breadcrumb" },
4
5
  { "id": "category.filter", "slot": "filter", "default": "CategoryFilter2" },
5
6
  {
6
7
  "id": "category.productCard",
@@ -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'@@SECTION_IMPORTS@@
32
+ import { categoryBreadcrumbProps, categoryFilterProps } from './props'
33
+ import { breadcrumbPropsSchema, categoryFilterPropsSchema } from './schema'@@SECTION_IMPORTS@@
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'@@SECTION_IMPORTS@@
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[]
@@ -75,15 +79,67 @@ const emptyFilterState = () => ({
75
79
  categoryFilterCategories: [],
76
80
  categoryFilterCollections: [],
77
81
  categoryFilterPriceRange: undefined,
82
+ breadcrumbItems: [] as { label: string; href?: string }[],
78
83
  })
79
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
+
80
136
  export const category: ViewModule = {
81
137
  name: 'category',
82
138
  elements,
83
139
  catalog,
84
140
  registry,
85
141
  route: {
86
- path: '/categorias/[[...slug]]',
142
+ path: '/categories/[[...slug]]',
87
143
  loaderName: 'category',
88
144
  layout: 'default',
89
145
  metadata: {
@@ -144,13 +200,13 @@ export const category: ViewModule = {
144
200
  const presentation = mapCatalogResultsToFilterProps({
145
201
  query: listing.query,
146
202
  results: listing.results,
147
- // A scoped listing (`/categorias/<handle>`) must not offer the
203
+ // A scoped listing (`/categories/<handle>`) must not offer the
148
204
  // category facet: the scope already fixes the category. The root
149
205
  // catalog passes no scope and keeps it.
150
206
  ...(slug ? { categoryScope: slug } : {}),
151
207
  // Product links point at the product-detail route. The runtime stays
152
208
  // route-agnostic, so this view supplies the href builder.
153
- productHref: (handle: string) => `/productos/${handle}`,
209
+ productHref: (handle: string) => `/products/${handle}`,
154
210
  ...(categories ? { categories } : {}),
155
211
  ...(collections ? { collections } : {}),
156
212
  sortOptions,
@@ -162,6 +218,8 @@ export const category: ViewModule = {
162
218
 
163
219
  return {
164
220
  storeCategories: catalog.categories,
221
+ categoryEntity: resolveCategoryEntity(slug, catalog),
222
+ breadcrumbItems: resolveBreadcrumbItems(slug, catalog),
165
223
  dataSource: catalog.source,
166
224
  dataError: null,
167
225
  categoryFilterProducts: presentation.products,
@@ -14,7 +14,8 @@ import {
14
14
  type CategoryFilter2ActiveFilter,
15
15
  type CategoryFilter2Filters,
16
16
  type CategoryFilter2Props,
17
- } from '@thorprovider/blocks'@@BLOCK_IMPORTS@@@@SECTION_REGISTRY_IMPORTS@@
17
+ } from '@thorprovider/blocks'
18
+ @@BLOCK_IMPORTS@@@@SECTION_REGISTRY_IMPORTS@@
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'
@@ -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
+ },