@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
@@ -12,11 +12,15 @@ import {
12
12
  logoAccent,
13
13
  navbarClassName,
14
14
  newsletter,
15
+ shippingNote,
15
16
  } from './content'
16
17
 
17
18
  export const navbarShellProps = {
18
19
  logoAccent,
19
20
  className: navbarClassName,
21
+ // Optional utility-strip note. Empty by default, so the strip renders only
22
+ // once a real note (or a utility link) is configured.
23
+ shippingNote,
20
24
  // Global navigation search is held until a verified backend search contract
21
25
  // exists (E2 task 5.4), so the generated storefront presents NO search
22
26
  // control. A merchant may set this to `true` to opt in once the contract is
@@ -6,6 +6,8 @@ import type { NextAppSpec } from '@json-render/next'
6
6
  import { storefrontInitialState } from '../state'
7
7
  import { views } from '../views'
8
8
  import { footerShellProps, navbarShellProps } from '../shell/props'
9
+ import { mobileBottomNavItems } from '../shell/content'
10
+ import { mountedFeatureConfigs, mountedFeatureNames } from '../composition'
9
11
  import type { StorefrontElement } from './types'
10
12
 
11
13
  export type { StorefrontElement }
@@ -34,12 +36,49 @@ const routes = Object.fromEntries(
34
36
 
35
37
  // The default layout is the storefront chrome shared by every page: the
36
38
  // system notice region sits in the notices slot (always first, above the
37
- // header), the announcement banner and navbar live in the header slot, the
38
- // footer in the footer slot, and the cart drawer is a page-level child of
39
- // Layout (rendered outside the slots so it can overlay the whole shell). This
39
+ // header), the announcement banner and navbar live in the header slot, and the
40
+ // footer in the footer slot. The cart is a lateral panel owned by the navbar
41
+ // (opened by the navbar icon or a successful add), never an inline element of
42
+ // the shell, so page content is never followed by a duplicated cart. This
40
43
  // mirrors the reference app so a fresh scaffold ships a real storefront, not a
41
44
  // bare page.
45
+ // The navigability feature contributes its chrome only when it is installed.
46
+ // `mountedFeatureNames` is the general presence seam (unfiltered by the catalog
47
+ // boundary), so a removed feature leaves no dangling skip link, back-to-top, or
48
+ // bottom navigation behind.
49
+ const hasNavigability = mountedFeatureNames.includes('navigability')
50
+ const navigabilityConfig = (mountedFeatureConfigs['navigability'] ?? {}) as {
51
+ showBackToTop?: boolean
52
+ }
53
+ const showBackToTop = navigabilityConfig.showBackToTop !== false
54
+
42
55
  const shellElements: Record<string, StorefrontElement> = {
56
+ // Navigability chrome, present only when the feature is installed. The skip
57
+ // link is the first focusable element (rendered before the notices); the
58
+ // back-to-top and mobile bottom navigation are fixed-position aids.
59
+ ...(hasNavigability
60
+ ? {
61
+ skipToContent: {
62
+ type: 'SkipToContent',
63
+ props: { targetId: 'main-content' },
64
+ },
65
+ ...(showBackToTop
66
+ ? {
67
+ backToTop: {
68
+ type: 'BackToTop',
69
+ props: { label: 'Volver arriba' },
70
+ },
71
+ }
72
+ : {}),
73
+ mobileBottomNav: {
74
+ type: 'MobileBottomNav',
75
+ props: {
76
+ items: mobileBottomNavItems,
77
+ ariaLabel: 'Navegación principal',
78
+ },
79
+ },
80
+ }
81
+ : {}),
43
82
  // Reserved top region for system/status messages. Only the shell layout
44
83
  // references it, so it renders once and always as the first element.
45
84
  notice: {
@@ -51,7 +90,7 @@ const shellElements: Record<string, StorefrontElement> = {
51
90
  props: {
52
91
  badge: 'Novedades',
53
92
  message: 'Descubre el catálogo de __SITE_NAME__.',
54
- href: '/categorias',
93
+ href: '/categories',
55
94
  linkLabel: 'Ver catálogo',
56
95
  },
57
96
  visible: { $state: '/bannerDismissed', not: true },
@@ -61,8 +100,9 @@ const shellElements: Record<string, StorefrontElement> = {
61
100
  type: 'StorefrontNavbar',
62
101
  props: {
63
102
  storeCategories: { $state: '/navCategories' },
64
- // Neutral store info: no delivery/COD/shipping promise is made by default.
65
- shippingNote: 'Catálogo en línea',
103
+ // No shipping/delivery/COD note is shipped by default; the note comes
104
+ // from the user-owned shell seam (`...navbarShellProps` below) and is
105
+ // empty unless the merchant sets one, so no placeholder strip renders.
66
106
  utilityLinks: [],
67
107
  logoText: '__SITE_NAME__',
68
108
  logoHref: '/',
@@ -72,29 +112,12 @@ const shellElements: Record<string, StorefrontElement> = {
72
112
  },
73
113
  on: {
74
114
  openCart: { action: 'openCart' },
115
+ goToCart: { action: 'goToCart' },
75
116
  goToCheckout: { action: 'goToCheckout' },
76
117
  applyQuantityDelta: { action: 'applyQuantityDelta' },
77
118
  removeFromCart: { action: 'removeFromCart' },
78
119
  },
79
120
  },
80
- 'cart.drawer': {
81
- type: 'CartDrawer',
82
- props: {
83
- // No fabricated free-shipping threshold or reward: the backend is the only
84
- // source of shipping amounts, and a promotion promise is not supported by
85
- // the default fixture. A merchant may add promotions as customization.
86
- promotions: [],
87
- taxRate: 0.0,
88
- },
89
- visible: { $state: '/cartOpen', eq: true },
90
- on: {
91
- updateQuantity: { action: 'updateQuantity' },
92
- removeFromCart: { action: 'removeFromCart' },
93
- goToCheckout: { action: 'goToCheckout' },
94
- closeCart: { action: 'closeCart' },
95
- refreshCart: { action: 'refreshCart' },
96
- },
97
- },
98
121
  footer: {
99
122
  type: 'Footer',
100
123
  props: {
@@ -123,8 +146,15 @@ export const __STOREFRONT_CAMEL__Spec: NextAppSpec = {
123
146
  layout: {
124
147
  type: 'Layout',
125
148
  props: {},
126
- slots: { notices: ['notice'], header: ['banner', 'navbar'], footer: ['footer'] },
127
- children: ['slot', 'cart.drawer'],
149
+ slots: {
150
+ ...(hasNavigability
151
+ ? { chrome: ['skipToContent', 'backToTop', 'mobileBottomNav'] }
152
+ : {}),
153
+ notices: ['notice'],
154
+ header: ['banner', 'navbar'],
155
+ footer: ['footer'],
156
+ },
157
+ children: ['slot'],
128
158
  },
129
159
  // The page content is injected at the Slot element by @json-render/next.
130
160
  // Without it the rendered layout drops the page and the store looks empty.
@@ -3,6 +3,12 @@
3
3
  // host files. Kept free of Next.js, React, and env imports so the logic is
4
4
  // testable at the source and the host files stay thin.
5
5
 
6
+ import type { EntitySeo } from '@thorprovider/runtime/contracts'
7
+ import {
8
+ mapCategoryEntityToSeo,
9
+ mapProductEntityToSeo,
10
+ } from '@thorprovider/runtime/contracts'
11
+
6
12
  /** The routes the storefront can serve as fixed pages (no dynamic `[` segment). */
7
13
  export function fixedRoutePaths(routes: Record<string, unknown>): string[] {
8
14
  return Object.keys(routes).filter((path) => !path.includes('['))
@@ -24,3 +30,162 @@ export function sitemapUrls(
24
30
  export function robotsSitemapUrl(siteUrl: string | undefined): string | undefined {
25
31
  return siteUrl ? new URL('/sitemap.xml', siteUrl).toString() : undefined
26
32
  }
33
+
34
+ /**
35
+ * Surfaces that are never public: they require a shopper session or hold
36
+ * checkout state and must not be indexed or listed in the sitemap.
37
+ */
38
+ export const PRIVATE_ROUTE_PREFIXES = ['/account', '/checkout'] as const
39
+
40
+ /** True when a path is (or is nested under) a private surface. */
41
+ export function isPrivatePath(path: string): boolean {
42
+ return PRIVATE_ROUTE_PREFIXES.some(
43
+ (prefix) => path === prefix || path.startsWith(`${prefix}/`),
44
+ )
45
+ }
46
+
47
+ /**
48
+ * The paths `robots.txt` disallows: the fixed private surfaces plus any private
49
+ * path the known route set declares. Public routes are never returned.
50
+ */
51
+ export function robotsDisallowPaths(routes: Record<string, unknown>): string[] {
52
+ const paths = new Set<string>(PRIVATE_ROUTE_PREFIXES)
53
+ for (const path of Object.keys(routes)) {
54
+ if (isPrivatePath(path)) paths.add(path)
55
+ }
56
+ return [...paths]
57
+ }
58
+
59
+ /**
60
+ * Per-route entity-SEO extractors keyed by the route's loader name. Each runs
61
+ * after the same request-scoped loader as the page and maps the real loaded
62
+ * entity through the pure `@thorprovider/runtime/contracts` mappers. The route
63
+ * prefix is supplied here because the runtime stays route-agnostic.
64
+ */
65
+ export const entitySeoExtractors: Record<
66
+ string,
67
+ (data: Record<string, unknown>) => EntitySeo | undefined
68
+ > = {
69
+ product: (data) =>
70
+ mapProductEntityToSeo(data.selectedProduct, { pathPrefix: '/products' }),
71
+ category: (data) =>
72
+ mapCategoryEntityToSeo(data.categoryEntity, { pathPrefix: '/categories' }),
73
+ }
74
+
75
+ /** A public catalog entity referenced by its stable id and public path. */
76
+ export interface PublicEntityRef {
77
+ id: string
78
+ path: string
79
+ }
80
+
81
+ /** One page of public entities plus the cursor for the next page, if any. */
82
+ export interface PublicEntityPage {
83
+ items: PublicEntityRef[]
84
+ nextCursor?: string
85
+ }
86
+
87
+ /** A page-fetching callback for the bounded entity enumeration. */
88
+ export type PublicEntityPageFetcher = (
89
+ cursor: string | undefined,
90
+ ) => Promise<PublicEntityPage>
91
+
92
+ /** Explicit reason an enumeration did not complete. */
93
+ export type SitemapEnumerationFailure =
94
+ | { kind: 'enumeration'; message: string }
95
+ | { kind: 'bound'; maxPages: number }
96
+ | { kind: 'cycle'; id: string }
97
+
98
+ export type SitemapEnumerationResult =
99
+ | { ok: true; entities: PublicEntityRef[] }
100
+ | { ok: false; failure: SitemapEnumerationFailure }
101
+
102
+ /** The traversal bound: an enumeration that exceeds it fails, never truncates. */
103
+ export const DEFAULT_SITEMAP_MAX_PAGES = 100
104
+
105
+ /**
106
+ * Enumerate public entities across pagination, de-duplicated by id, with a
107
+ * visited-id cycle guard and a maximum-page bound. A fetch failure, a bound hit,
108
+ * or a repeated id/cursor returns an explicit typed failure instead of an
109
+ * apparently-complete or truncated list.
110
+ */
111
+ export async function enumeratePublicEntities(
112
+ fetchPage: PublicEntityPageFetcher,
113
+ options: { maxPages?: number } = {},
114
+ ): Promise<SitemapEnumerationResult> {
115
+ const maxPages = options.maxPages ?? DEFAULT_SITEMAP_MAX_PAGES
116
+ const seenIds = new Set<string>()
117
+ const seenCursors = new Set<string>()
118
+ const entities: PublicEntityRef[] = []
119
+ let cursor: string | undefined
120
+
121
+ for (let pages = 0; ; pages += 1) {
122
+ if (pages >= maxPages) {
123
+ return { ok: false, failure: { kind: 'bound', maxPages } }
124
+ }
125
+
126
+ let page: PublicEntityPage
127
+ try {
128
+ page = await fetchPage(cursor)
129
+ } catch (error) {
130
+ return {
131
+ ok: false,
132
+ failure: {
133
+ kind: 'enumeration',
134
+ message: error instanceof Error ? error.message : String(error),
135
+ },
136
+ }
137
+ }
138
+
139
+ for (const item of page.items) {
140
+ if (seenIds.has(item.id)) {
141
+ return { ok: false, failure: { kind: 'cycle', id: item.id } }
142
+ }
143
+ seenIds.add(item.id)
144
+ entities.push(item)
145
+ }
146
+
147
+ const next = page.nextCursor
148
+ if (next === undefined || next === '') {
149
+ return { ok: true, entities }
150
+ }
151
+ if (seenCursors.has(next)) {
152
+ return { ok: false, failure: { kind: 'cycle', id: next } }
153
+ }
154
+ seenCursors.add(next)
155
+ cursor = next
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Raised by `app/sitemap.ts` when enumeration fails, so the route responds with
161
+ * a non-success instead of an apparently-complete sitemap.
162
+ */
163
+ export class SitemapEnumerationError extends Error {
164
+ readonly failure: SitemapEnumerationFailure
165
+ constructor(failure: SitemapEnumerationFailure) {
166
+ super(`Sitemap enumeration failed (${failure.kind})`)
167
+ this.name = 'SitemapEnumerationError'
168
+ this.failure = failure
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Combine the fixed public routes with the enumerated public entities into
174
+ * de-duplicated absolute sitemap entries. Private surfaces are never listed.
175
+ */
176
+ export function buildSitemapEntries(input: {
177
+ routes: Record<string, unknown>
178
+ siteUrl: string
179
+ entities: readonly PublicEntityRef[]
180
+ }): { url: string }[] {
181
+ const urls = new Set<string>()
182
+ for (const path of fixedRoutePaths(input.routes)) {
183
+ if (!isPrivatePath(path)) urls.add(new URL(path, input.siteUrl).toString())
184
+ }
185
+ for (const entity of input.entities) {
186
+ if (!isPrivatePath(entity.path)) {
187
+ urls.add(new URL(entity.path, input.siteUrl).toString())
188
+ }
189
+ }
190
+ return [...urls].map((url) => ({ url }))
191
+ }
@@ -9,8 +9,10 @@
9
9
  // owns the environment contract; the commerce bridge owns the backend calls.
10
10
  //
11
11
  // The values are the multitenancy seam: a future tenancy layer varies
12
- // storeId/channel/region/currency without redefining the commerce shapes. The
13
- // currency is NEVER hardcoded — it always comes from the host environment.
12
+ // storeId/channel/region/currency without redefining the commerce shapes. In
13
+ // real mode the currency is NEVER hardcoded — it comes from the host
14
+ // environment; mock mode resolves a labelled fixture context so the store runs
15
+ // with no environment configured at all.
14
16
 
15
17
  import type { StoreContext } from '@thorprovider/types'
16
18
  import { storeContextSchema } from '@thorprovider/runtime/contracts'
@@ -23,7 +25,7 @@ export const STORE_CONTEXT_ENV = {
23
25
  storeId: 'THOR_STORE_ID',
24
26
  salesChannelId: 'MEDUSA_SALES_CHANNEL_ID',
25
27
  regionId: 'MEDUSA_REGION_ID',
26
- currencyCode: 'THOR_CURRENCY_CODE',
28
+ currencyCode: 'MEDUSA_CURRENCY_CODE',
27
29
  } as const
28
30
 
29
31
  /** Real-mode backend configuration required before any provider call. */
@@ -90,6 +92,19 @@ export function getSiteUrl(): string | undefined {
90
92
  return parseSiteUrl(process.env[SITE_URL_ENV])
91
93
  }
92
94
 
95
+ /**
96
+ * Fixture context resolved in mock mode. It satisfies `storeContextSchema` and
97
+ * mirrors the values the bundled mock provider serves, so a store with no
98
+ * environment configured still has a valid context to render and operate its
99
+ * cart against. It is a labelled fixture, never a real backend identity.
100
+ */
101
+ export const MOCK_STORE_CONTEXT: StoreContext = {
102
+ storeId: 'mock-store',
103
+ salesChannelId: 'mock-sales-channel',
104
+ regionId: 'mock-region-us',
105
+ currencyCode: 'USD',
106
+ }
107
+
93
108
  function requireEnv(name: string): string {
94
109
  const value = process.env[name]
95
110
  if (typeof value !== 'string' || value.trim() === '') {
@@ -101,13 +116,17 @@ function requireEnv(name: string): string {
101
116
  }
102
117
 
103
118
  /**
104
- * Resolve and validate the single-store context from the host environment.
119
+ * Resolve and validate the single-store context.
105
120
  *
106
- * Validated through the shared runtime schema, so an invalid context is rejected
107
- * before any commerce call. Throws `StoreConfigError` naming the missing
108
- * variable — never a secret value.
121
+ * Mock mode returns the labelled fixture context and reads no environment
122
+ * variables, so a created store runs with no configuration. Real mode resolves
123
+ * the four variables from the host environment and validates them through the
124
+ * shared runtime schema, so an invalid context is rejected before any commerce
125
+ * call. Throws `StoreConfigError` naming the missing variable — never a secret
126
+ * value.
109
127
  */
110
128
  export function getStoreContext(): StoreContext {
129
+ if (!isRealMode()) return MOCK_STORE_CONTEXT
111
130
  const candidate = {
112
131
  storeId: requireEnv(STORE_CONTEXT_ENV.storeId),
113
132
  salesChannelId: requireEnv(STORE_CONTEXT_ENV.salesChannelId),
@@ -8,8 +8,9 @@
8
8
  import type { ViewCatalogEntry } from '../types'
9
9
  import { ctaSection } from './cta'
10
10
  import { faqSection } from './faq'
11
+ import { productsSection } from './products'
11
12
 
12
- export const sectionModules = [faqSection, ctaSection] as const
13
+ export const sectionModules = [faqSection, ctaSection, productsSection] as const
13
14
 
14
15
  export const sectionCatalog: Record<string, ViewCatalogEntry> = Object.assign(
15
16
  {},
@@ -0,0 +1,100 @@
1
+ 'use client'
2
+
3
+ // Optional section fragment: products (mirrors recipes/sections.json) — client registry.
4
+ // The matching server-side catalog lives in products.tsx. A data-bound section
5
+ // renders one of two verified presentations (ProductCard1 grid, or the combined
6
+ // grid-and-carousel ProductShowcase1) chosen by the resolved `variant`. The
7
+ // selection and resolved products arrive through the view state; this boundary
8
+ // only validates them and wires navigation/purchase callbacks.
9
+
10
+ import {
11
+ ProductCard1 as ProductCard1Block,
12
+ ProductShowcase1 as ProductShowcase1Block,
13
+ type ProductCard1Product,
14
+ } from '@thorprovider/blocks'
15
+ import { useStateStore } from '@json-render/react'
16
+ import { useSectionObservation } from '../../section-observation'
17
+ import { productsPropsSchema, type ProductsStatus } from './schema.products'
18
+ import type { ViewRegistry } from '../types'
19
+
20
+ // Truthful, actionable messages for the non-`ready` states. A failed or
21
+ // unconfigured section never fabricates products.
22
+ function sectionStatusMessage(status: ProductsStatus): string {
23
+ switch (status) {
24
+ case 'unconfigured':
25
+ return 'Esta sección de productos aún no tiene una selección configurada.'
26
+ case 'empty':
27
+ return 'Esta selección no tiene productos disponibles por ahora.'
28
+ case 'invalid-query':
29
+ return 'La selección de productos de esta sección no es válida.'
30
+ case 'unsupported-query':
31
+ return 'Esta selección usa criterios que el catálogo no admite.'
32
+ case 'unmounted-capability':
33
+ return 'Esta selección requiere una capacidad que este catálogo no tiene montada.'
34
+ case 'data-error':
35
+ return 'No se pudieron cargar los productos de esta sección.'
36
+ default:
37
+ return ''
38
+ }
39
+ }
40
+
41
+ export const productsRegistry: ViewRegistry = {
42
+ Products: ({ props, emit }) => {
43
+ const { set } = useStateStore()
44
+ const parsed = productsPropsSchema.parse(props)
45
+ const { containerRef, noteProductClick } = useSectionObservation({
46
+ sectionId: parsed.sectionId ?? '',
47
+ sectionType: parsed.sectionType ?? 'products',
48
+ ...(parsed.variant ? { sectionVariant: parsed.variant } : {}),
49
+ ...(parsed.objective ? { objective: parsed.objective } : {}),
50
+ })
51
+
52
+ if (parsed.status !== 'ready') {
53
+ return (
54
+ <section
55
+ aria-label={parsed.heading ?? 'Productos'}
56
+ className="mx-auto w-full max-w-7xl px-4 py-12"
57
+ >
58
+ {parsed.heading ? (
59
+ <h2 className="mb-2 text-2xl font-semibold tracking-tight">{parsed.heading}</h2>
60
+ ) : null}
61
+ <p className="text-sm text-muted-foreground">{sectionStatusMessage(parsed.status)}</p>
62
+ </section>
63
+ )
64
+ }
65
+
66
+ // The detail affordance opens the product so a variant is chosen explicitly.
67
+ // It is also the section's observation origin (disabled by default).
68
+ const openProduct = (product: ProductCard1Product) => {
69
+ noteProductClick(product)
70
+ set('/lastClicked', product)
71
+ emit('openProduct')
72
+ }
73
+
74
+ // Product-card shortcut: the add affordance adds the product's first
75
+ // purchasable variant (resolved server-side from the product id), then opens
76
+ // the mini-cart. It is not the section's observation origin.
77
+ const addToCart = (product: ProductCard1Product) => {
78
+ set('/lastClicked', product)
79
+ emit('addClickedToCart')
80
+ }
81
+
82
+ const shared = {
83
+ products: parsed.products,
84
+ heading: parsed.heading,
85
+ description: parsed.description,
86
+ onAddToCart: addToCart,
87
+ onProductClick: openProduct,
88
+ }
89
+
90
+ return (
91
+ <div ref={containerRef}>
92
+ {parsed.variant === 'ProductShowcase1' ? (
93
+ <ProductShowcase1Block {...shared} />
94
+ ) : (
95
+ <ProductCard1Block {...shared} />
96
+ )}
97
+ </div>
98
+ )
99
+ },
100
+ }
@@ -0,0 +1,19 @@
1
+ // Optional section fragment: products (mirrors recipes/sections.json)
2
+ // Server-side part: element type + zod catalog entry. The component mappings
3
+ // live in products.client.tsx ('use client'). Unlike the stateless editorial
4
+ // fragments (faq/cta), the products section is data-bound: its per-instance
5
+ // selection is resolved by the host loader and delivered through the view state,
6
+ // so this catalog entry describes the *resolved* props shape.
7
+
8
+ import { productsPropsSchema } from './schema.products'
9
+
10
+ export const productsSection = {
11
+ elementType: 'Products',
12
+ catalog: {
13
+ Products: {
14
+ props: productsPropsSchema,
15
+ description: 'Data-bound product presentation (grid or combined grid-and-carousel).',
16
+ },
17
+ },
18
+ registryModule: './products.client',
19
+ }
@@ -0,0 +1,39 @@
1
+ // Optional section fragment: products (mirrors recipes/sections.json) — shared contract.
2
+ // Zod mirror of the resolved Products section props, shared by the server catalog
3
+ // (products.tsx) and the client registry (products.client.tsx). The product list
4
+ // reuses the canonical presentation contract owned by @thorprovider/blocks so the
5
+ // section renders exactly what the host mapped from the catalog result.
6
+
7
+ import { z } from 'zod'
8
+ import { productPresentationSchema } from '@thorprovider/blocks'
9
+
10
+ // The per-instance resolution status the host loader computes for a data-bound
11
+ // section. `ready`/`empty` carry resolved products; the remaining states carry no
12
+ // products and render an actionable, truthful message instead.
13
+ export const productsStatusSchema = z.enum([
14
+ 'unconfigured',
15
+ 'ready',
16
+ 'empty',
17
+ 'invalid-query',
18
+ 'unsupported-query',
19
+ 'unmounted-capability',
20
+ 'data-error',
21
+ ])
22
+
23
+ export const productsPropsSchema = z.object({
24
+ sectionId: z.string().min(1).optional(),
25
+ sectionType: z.string().min(1).optional(),
26
+ variant: z.string().optional(),
27
+ // Defensive defaults: a missing resolved state (e.g. the view loader could not
28
+ // run) degrades to a truthful error instead of crashing the render.
29
+ status: productsStatusSchema.default('data-error'),
30
+ products: z.array(productPresentationSchema).default([]),
31
+ heading: z.string().optional(),
32
+ description: z.string().optional(),
33
+ objective: z.string().optional(),
34
+ ctaLabel: z.string().optional(),
35
+ ctaHref: z.string().optional(),
36
+ })
37
+
38
+ export type ProductsProps = z.infer<typeof productsPropsSchema>
39
+ export type ProductsStatus = z.infer<typeof productsStatusSchema>
@@ -15,6 +15,7 @@ import {
15
15
  syncCheckoutAddressAction,
16
16
  } from '../../actions'
17
17
  import type { CartStatePatch, PlaceOrderResult } from '../../commerce'
18
+ import { recordConfirmedTransition } from '../../section-observation'
18
19
 
19
20
  // The always-required core of an address. Optional fields (province, phone,
20
21
  // postal code) are left to the runtime validator so the host does not impose a
@@ -49,6 +50,9 @@ export function useCheckoutHandlers() {
49
50
  const applyResult = (result: PlaceOrderResult) => {
50
51
  set('/checkoutPending', false)
51
52
  if (result.status === 'order') {
53
+ // A confirmed, persisted order — observed only on a genuine order result
54
+ // (not captured funds) and only when a section origin exists.
55
+ recordConfirmedTransition('order_confirmed')
52
56
  // ONE atomic write carries the order and its backend lines.
53
57
  set('/lastReceipt', { order: result.order, lines: result.lines })
54
58
  set('/orderStatus', 'complete')
@@ -93,6 +97,8 @@ export function useCheckoutHandlers() {
93
97
 
94
98
  const handlers: Record<string, () => void> = {
95
99
  goToCheckout: () => {
100
+ // A confirmed checkout transition (the shopper started checkout).
101
+ recordConfirmedTransition('checkout_started')
96
102
  set('/cartOpen', false)
97
103
  navigate('/checkout')
98
104
  },
@@ -19,7 +19,13 @@ export interface CustomerAuthState {
19
19
  accountOrdersPending: boolean
20
20
  /** The order-history load failure, or null when it loaded. */
21
21
  accountOrdersError: string | null
22
- /** The order number selected for the inline detail; null when none is open. */
22
+ /** The backend's real total for the authenticated customer on the store channel. */
23
+ accountOrdersCount: number
24
+ /** The 1-based order-history page currently loaded. */
25
+ accountOrdersPage: number
26
+ /** The bounded page size used for the order-history window. */
27
+ accountOrdersPageSize: number
28
+ /** The real backend order id selected for the inline detail; null when none is open. */
23
29
  accountSelectedOrderId: string | null
24
30
  }
25
31
 
@@ -32,5 +38,8 @@ export const customerAuthState: CustomerAuthState = {
32
38
  accountOrders: [],
33
39
  accountOrdersPending: false,
34
40
  accountOrdersError: null,
41
+ accountOrdersCount: 0,
42
+ accountOrdersPage: 1,
43
+ accountOrdersPageSize: 10,
35
44
  accountSelectedOrderId: null,
36
45
  }
@@ -95,7 +95,7 @@ export const account: ViewModule = {
95
95
  catalog,
96
96
  registry,
97
97
  route: {
98
- path: '/cuenta',
98
+ path: '/account',
99
99
  loaderName: 'account',
100
100
  layout: 'default',
101
101
  metadata: {