@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
@@ -5,25 +5,34 @@
5
5
  //
6
6
  // The shell renders the live backend cart: the Navbar mini-cart and the cart
7
7
  // drawer read the authoritative cart state and dispatch cart mutations to the
8
- // Server Actions. Add-to-cart from a listing navigates to the product detail so
9
- // a variant is chosen explicitly (there is no first-variant shortcut).
8
+ // Server Actions. A listing card's add-to-cart adds the product's first
9
+ // purchasable variant (the card shortcut); opening a product from a listing
10
+ // navigates to the detail, where a variant is chosen explicitly.
10
11
 
11
12
  import { defineRegistry, useStateStore } from '@json-render/react'
13
+ import { useEffect } from 'react'
14
+ import { usePathname } from 'next/navigation'
12
15
  import {
16
+ BackToTop as BackToTopBlock,
13
17
  Banner1,
14
18
  FooterSection1,
19
+ MobileBottomNav as MobileBottomNavBlock,
15
20
  Navbar1,
16
21
  Navbar4,
17
22
  ProductCard1,
18
- ShoppingCart4,
23
+ SkipToContent as SkipToContentBlock,
24
+ type BackToTopProps,
25
+ type MobileBottomNavProps,
26
+ type SkipToContentProps,
19
27
  } from '@thorprovider/blocks'
28
+ import { isAvailableMoney } from '@thorprovider/blocks/contracts'
29
+ import type { Money } from '@thorprovider/types'
20
30
  import { catalog } from './catalog'
21
31
  import type { CartLineState, CartTotalsState } from './state'
22
32
  import { useStorefrontHandlers } from './handlers'
23
- import { resolveIcon } from './icon-map'
33
+ import { cartViewAvailable, checkoutAvailable, mountedFeatureNames } from './composition'
24
34
  import {
25
35
  bannerPropsSchema,
26
- cartDrawerPropsSchema,
27
36
  footerPropsSchema,
28
37
  navbarPropsSchema,
29
38
  productCardPropsSchema,
@@ -40,10 +49,12 @@ interface NavCartItem {
40
49
  price: number
41
50
  qty: number
42
51
  image?: string
52
+ lineTotal?: Money
43
53
  }
44
54
 
45
55
  /** CartLineState -> Navbar4 cart line. `price` is legacy; the authoritative
46
- * subtotal is passed separately, so it is never computed from the line. */
56
+ * subtotal and per-line total are passed separately, so neither is computed
57
+ * from the line. */
47
58
  function toNavCartItems(cart: CartLineState[]): NavCartItem[] {
48
59
  return cart.map((line) => ({
49
60
  id: line.id,
@@ -52,19 +63,36 @@ function toNavCartItems(cart: CartLineState[]): NavCartItem[] {
52
63
  price: 0,
53
64
  qty: line.quantity,
54
65
  image: line.image,
66
+ lineTotal: line.lineTotal,
55
67
  }))
56
68
  }
57
69
 
58
70
  const baseComponents = {
59
71
  Layout: ({ children, slots }: { children?: React.ReactNode; slots?: Record<string, React.ReactNode> }) => {
60
72
  useStorefrontHandlers()
73
+ const { set } = useStateStore()
74
+ const pathname = usePathname()
75
+ // The mobile bottom navigation is feature-gated; when it is installed the
76
+ // main content reserves bottom padding so the fixed bar never obscures it.
77
+ const hasBottomNav = mountedFeatureNames.includes('navigability')
78
+ // Central dismissal: any route or history change closes the shared cart
79
+ // lateral, so it never lingers across navigation (forward/back included).
80
+ useEffect(() => {
81
+ set('/cartOpen', false)
82
+ }, [pathname, set])
61
83
  return (
62
84
  <div className="flex min-h-screen flex-col">
85
+ {slots?.chrome}
63
86
  {slots?.notices}
64
87
  <header className="sticky top-0 z-40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
65
88
  {slots?.header}
66
89
  </header>
67
- <main className="flex-1">{children}</main>
90
+ <main
91
+ id="main-content"
92
+ className={hasBottomNav ? 'flex-1 pb-16 md:pb-0' : 'flex-1'}
93
+ >
94
+ {children}
95
+ </main>
68
96
  <footer>{slots?.footer}</footer>
69
97
  </div>
70
98
  )
@@ -72,6 +100,22 @@ const baseComponents = {
72
100
  // Neutral page root: renders the route page without shell chrome. Using it
73
101
  // instead of `Layout` keeps the shell from mounting twice per page.
74
102
  Page: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
103
+ // Navigability chrome, feature-gated by the shell spec (only referenced when
104
+ // the feature is installed). Props are cast to the block contracts; the
105
+ // catalog schema is the runtime guard.
106
+ SkipToContent: ({ props }: { props: Record<string, unknown> }) => {
107
+ const p = props as unknown as SkipToContentProps
108
+ return <SkipToContentBlock {...p} />
109
+ },
110
+ BackToTop: ({ props }: { props: Record<string, unknown> }) => {
111
+ const p = props as unknown as BackToTopProps
112
+ return <BackToTopBlock {...p} />
113
+ },
114
+ MobileBottomNav: ({ props }: { props: Record<string, unknown> }) => {
115
+ const pathname = usePathname()
116
+ const p = props as unknown as MobileBottomNavProps
117
+ return <MobileBottomNavBlock {...p} currentHref={p.currentHref ?? pathname} />
118
+ },
75
119
  // Reserved top region for system/status messages. Self-gates on state and
76
120
  // collapses to nothing when there is no notice. A configured mock is labelled
77
121
  // as demo data; a real-mode failure is surfaced visibly, never hidden.
@@ -112,6 +156,18 @@ const baseComponents = {
112
156
  const { state, set } = useStateStore()
113
157
  const cart = (state.cart ?? []) as CartLineState[]
114
158
  const totals = state.cartTotals as CartTotalsState | null
159
+ const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
160
+ const readError = typeof state.cartReadError === 'string' ? state.cartReadError : undefined
161
+ const errorLabel = cartError ?? readError
162
+ const cartState = state.cartPending ? 'pending' : errorLabel ? 'error' : 'idle'
163
+ // Checkout is offered only when the required backend amounts are present and
164
+ // valid: a missing/malformed subtotal, total, or line amount blocks it, while
165
+ // an undetermined shipping amount does not (AC-CART-4.4 / UC-CART-7).
166
+ const amountsValid =
167
+ cart.every((line) => isAvailableMoney(line.lineTotal)) &&
168
+ totals !== null &&
169
+ isAvailableMoney(totals.subtotal) &&
170
+ isAvailableMoney(totals.total)
115
171
  return (
116
172
  <Navbar4
117
173
  storeCategories={parsed.storeCategories}
@@ -122,13 +178,30 @@ const baseComponents = {
122
178
  logoAccent={parsed.logoAccent}
123
179
  className={parsed.className}
124
180
  accountHref={parsed.accountHref}
181
+ // The current route marks the matching category link as the current page
182
+ // (accessibly, beyond color alone); no link is marked when it does not
183
+ // match any destination.
184
+ currentPath={usePathname()}
125
185
  // Held global search (E2 task 5.4): default to hidden so no global
126
186
  // search is presented while the contract is unverified; the user-owned
127
187
  // shell seam may opt in with `showSearch: true`.
128
188
  showSearch={parsed.showSearch ?? false}
129
189
  items={toNavCartItems(cart)}
130
190
  subtotal={totals?.subtotal}
131
- onViewCart={() => emit('openCart')}
191
+ // One shared lateral: visibility is driven by the shared `cartOpen`
192
+ // state (a successful add opens it; navigation dismisses it). The
193
+ // canonical line amounts and lifecycle states come from the same cart
194
+ // state the standalone `/cart` view reads.
195
+ canonicalCart
196
+ cartState={cartState}
197
+ cartErrorLabel={errorLabel}
198
+ onCartRetry={() => emit('refreshCart')}
199
+ cartOpen={state.cartOpen === true}
200
+ onCartOpenChange={(open) => set('/cartOpen', open)}
201
+ checkoutUnavailable={!checkoutAvailable}
202
+ checkoutDisabled={cartState !== 'idle' || cart.length === 0 || !amountsValid}
203
+ viewCartUnavailable={!cartViewAvailable}
204
+ onViewCart={() => emit('goToCart')}
132
205
  onCheckout={() => emit('goToCheckout')}
133
206
  onQtyChange={(id, delta) => {
134
207
  set('/qtyDelta', { id, delta })
@@ -141,47 +214,6 @@ const baseComponents = {
141
214
  />
142
215
  )
143
216
  },
144
- CartDrawer: ({ props, emit }: { props: Record<string, unknown>; emit: (event: string) => void }) => {
145
- const parsed = cartDrawerPropsSchema.parse(props)
146
- const { state, set } = useStateStore()
147
- const cart = (state.cart ?? []) as CartLineState[]
148
- const totals = state.cartTotals as CartTotalsState | null
149
- const cartError = typeof state.cartError === 'string' ? state.cartError : undefined
150
- return (
151
- <ShoppingCart4
152
- items={cart.map((line) => ({
153
- id: line.id,
154
- name: line.name,
155
- price: 0,
156
- quantity: line.quantity,
157
- image: line.image,
158
- variant: line.variant,
159
- lineTotal: line.lineTotal,
160
- unitPrice: line.unitPrice,
161
- }))}
162
- promotions={parsed.promotions.map((promo) => ({
163
- threshold: promo.threshold,
164
- reward: promo.reward,
165
- icon: resolveIcon(promo.icon),
166
- }))}
167
- totals={totals ?? undefined}
168
- totalsState={state.cartPending ? 'pending' : cartError ? 'error' : 'idle'}
169
- totalsErrorLabel={cartError}
170
- onRetryTotals={() => emit('refreshCart')}
171
- onQuantityChange={(id, quantity) => {
172
- const line = cart.find((candidate) => candidate.id === id)
173
- if (!line) return
174
- set('/qtyUpdate', { id, merchandiseId: line.merchandiseId, quantity })
175
- emit('updateQuantity')
176
- }}
177
- onRemove={(id) => {
178
- set('/lastRemoved', id)
179
- emit('removeFromCart')
180
- }}
181
- onCheckout={() => emit('goToCheckout')}
182
- />
183
- )
184
- },
185
217
  Banner: ({ props, emit }: { props: Record<string, unknown>; emit: (event: string) => void }) => {
186
218
  const parsed = bannerPropsSchema.parse(props)
187
219
  const { set } = useStateStore()
@@ -231,7 +263,7 @@ const baseComponents = {
231
263
  products={parsed.products}
232
264
  heading={parsed.heading}
233
265
  maxItems={parsed.maxItems}
234
- // No first-variant shortcut: open the detail so a variant is chosen.
266
+ // This base card opens the detail so a variant is chosen explicitly.
235
267
  onAddToCart={(product) => {
236
268
  select(product)
237
269
  emit('openProduct')
@@ -5,6 +5,8 @@
5
5
  import type { StorefrontLoaderFn } from '@thorprovider/runtime/render-core/app'
6
6
  import { createStorefrontApp } from '@thorprovider/runtime/render-core/app'
7
7
  import { __STOREFRONT_CAMEL__Spec } from './spec'
8
+ import { entitySeoExtractors } from './spec/seo'
9
+ import { getSiteUrl } from './store-context'
8
10
  import { views } from './views'
9
11
 
10
12
  const loaders: Record<string, StorefrontLoaderFn> = Object.assign({}, ...views.map((view) => view.loader))
@@ -12,6 +14,7 @@ const loaders: Record<string, StorefrontLoaderFn> = Object.assign({}, ...views.m
12
14
  export const __STOREFRONT_CAMEL__App = createStorefrontApp({
13
15
  spec: __STOREFRONT_CAMEL__Spec,
14
16
  loaders,
17
+ entitySeo: { siteUrl: getSiteUrl(), extract: entitySeoExtractors },
15
18
  })
16
19
 
17
20
  export type { StorefrontLoaderFn }
@@ -70,7 +70,7 @@ export const shellAccentSchema = z.object({
70
70
 
71
71
  export const storefrontNavbarPropsSchema = z.object({
72
72
  storeCategories: z.array(navCategorySchema),
73
- shippingNote: z.string(),
73
+ shippingNote: z.string().optional(),
74
74
  utilityLinks: z.array(z.object({ label: z.string(), href: z.string() })),
75
75
  logoText: z.string(),
76
76
  logoHref: z.string().optional(),
@@ -82,19 +82,6 @@ export const storefrontNavbarPropsSchema = z.object({
82
82
  showSearch: z.boolean().optional(),
83
83
  })
84
84
 
85
- // Mirrors ShoppingCart4 (block) minus the runtime-bound `items`; the registry
86
- // fills items from /cart. promotions carry icon NAMES resolved via icon-map.
87
- export const cartPromotionSchema = z.object({
88
- threshold: z.number(),
89
- reward: z.string(),
90
- icon: z.string(),
91
- })
92
-
93
- export const cartDrawerPropsSchema = z.object({
94
- promotions: z.array(cartPromotionSchema),
95
- taxRate: z.number().optional(),
96
- })
97
-
98
85
  export const bannerPropsSchema = z.object({
99
86
  badge: z.string().optional(),
100
87
  message: z.string(),
@@ -119,3 +106,31 @@ export const footerPropsSchema = z.object({
119
106
  .object({ placeholder: z.string().optional(), buttonLabel: z.string().optional() })
120
107
  .optional(),
121
108
  })
109
+
110
+ // Navigability chrome contracts, mirrored from @thorprovider/blocks so the
111
+ // shell catalog/registry share the scaffold's zod version.
112
+ export const skipToContentPropsSchema = z.object({
113
+ targetId: z.string(),
114
+ label: z.string().optional(),
115
+ className: z.string().optional(),
116
+ })
117
+
118
+ export const backToTopPropsSchema = z.object({
119
+ threshold: z.number().optional(),
120
+ label: z.string().optional(),
121
+ className: z.string().optional(),
122
+ })
123
+
124
+ export const mobileBottomNavItemSchema = z.object({
125
+ id: z.string().optional(),
126
+ label: z.string(),
127
+ href: z.string(),
128
+ icon: z.string().optional(),
129
+ })
130
+
131
+ export const mobileBottomNavPropsSchema = z.object({
132
+ items: z.array(mobileBottomNavItemSchema),
133
+ currentHref: z.string().optional(),
134
+ ariaLabel: z.string().optional(),
135
+ className: z.string().optional(),
136
+ })
@@ -0,0 +1,182 @@
1
+ 'use client'
2
+
3
+ // Generated by Thor Commerce storefront (host: section observation)
4
+ //
5
+ // Optional, host-owned merchandising observation. DISABLED BY DEFAULT: nothing is
6
+ // observed, transmitted, or stored until the host registers an adapter through
7
+ // `configureSectionObservation`. The pure controller (validation, impression
8
+ // dedupe, conservative lineage) lives in @thorprovider/runtime/contracts; this
9
+ // module only adds the browser glue (visibility, per-view-visit identity) and the
10
+ // adapter seam. Adapter failures never interrupt navigation, cart, or checkout.
11
+
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import {
14
+ createInMemoryRecorder,
15
+ createObservationController,
16
+ type ConfirmedEvent,
17
+ type ObservationContext,
18
+ type ObservationController,
19
+ type SectionObservationAdapter,
20
+ } from '@thorprovider/runtime/contracts'
21
+
22
+ let controller: ObservationController | null = null
23
+ let viewVisitId: string | null = null
24
+ let viewVisitSeq = 0
25
+
26
+ function nextViewVisitId(): string {
27
+ viewVisitSeq += 1
28
+ return `${Date.now().toString(36)}-${viewVisitSeq}`
29
+ }
30
+
31
+ /** Stable for one view visit (page load). */
32
+ function getViewVisitId(): string {
33
+ if (!viewVisitId) viewVisitId = nextViewVisitId()
34
+ return viewVisitId
35
+ }
36
+
37
+ function reportFailure(scope: string, error: unknown): void {
38
+ console.error(
39
+ `[storefront/observation] ${scope} failed`,
40
+ error instanceof Error ? error.message : error,
41
+ )
42
+ }
43
+
44
+ /** Register (or clear) the host adapter. Null disables observation entirely. */
45
+ export function configureSectionObservation(adapter: SectionObservationAdapter | null): void {
46
+ controller = adapter
47
+ ? createObservationController({
48
+ adapter,
49
+ onError: (error) => reportFailure('adapter', error),
50
+ })
51
+ : null
52
+ }
53
+
54
+ export function isSectionObservationEnabled(): boolean {
55
+ return controller !== null
56
+ }
57
+
58
+ export function getSectionObservationController(): ObservationController | null {
59
+ return controller
60
+ }
61
+
62
+ /** An in-memory recorder for verification/tests (never a production sink). */
63
+ export function createInMemorySectionRecorder(): ReturnType<typeof createInMemoryRecorder> {
64
+ return createInMemoryRecorder()
65
+ }
66
+
67
+ export function setSectionObservationContext(context?: ObservationContext): void {
68
+ try {
69
+ controller?.setContext(context)
70
+ } catch (error) {
71
+ reportFailure('setContext', error)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Observe a confirmed host transition. Only recorded when a section origin
77
+ * exists for the current session/context; otherwise it stays unattributed.
78
+ */
79
+ export function recordConfirmedTransition(
80
+ event: ConfirmedEvent,
81
+ input?: { productHandle?: string; destination?: string },
82
+ ): void {
83
+ try {
84
+ controller?.noteConfirmed(event, input)
85
+ } catch (error) {
86
+ reportFailure(event, error)
87
+ }
88
+ }
89
+
90
+ export interface UseSectionObservationInput {
91
+ sectionId: string
92
+ sectionType: string
93
+ sectionVariant?: string
94
+ objective?: string
95
+ context?: ObservationContext
96
+ }
97
+
98
+ /**
99
+ * Observe a section's exposure and product clicks. When observation is disabled
100
+ * no IntersectionObserver is attached and the returned callback is a no-op.
101
+ */
102
+ export function useSectionObservation(input: UseSectionObservationInput) {
103
+ const enabled = isSectionObservationEnabled() && input.sectionId.length > 0
104
+ const containerRef = useRef<HTMLDivElement | null>(null)
105
+ const [inView, setInView] = useState(false)
106
+ const { sectionId, sectionType, sectionVariant, objective, context } = input
107
+
108
+ useEffect(() => {
109
+ if (!enabled) return
110
+ const element = containerRef.current
111
+ if (!element || typeof IntersectionObserver === 'undefined') return
112
+ const observer = new IntersectionObserver(
113
+ (entries) => {
114
+ for (const entry of entries) {
115
+ if (entry.intersectionRatio >= 0.5) setInView(true)
116
+ }
117
+ },
118
+ { threshold: [0.5] },
119
+ )
120
+ observer.observe(element)
121
+ return () => observer.disconnect()
122
+ }, [enabled])
123
+
124
+ useEffect(() => {
125
+ if (!enabled || !inView) return
126
+ const timer = setTimeout(() => {
127
+ controller?.observeImpression({
128
+ viewVisitId: getViewVisitId(),
129
+ sectionId,
130
+ sectionType,
131
+ ...(sectionVariant ? { sectionVariant } : {}),
132
+ ...(objective ? { objective } : {}),
133
+ ...(context ? { context } : {}),
134
+ })
135
+ }, 1000)
136
+ return () => clearTimeout(timer)
137
+ }, [enabled, inView, sectionId, sectionType, sectionVariant, objective, context])
138
+
139
+ const noteProductClick = (product: { id: string | number; href?: string }): void => {
140
+ if (!enabled) return
141
+ try {
142
+ controller?.noteClick({
143
+ viewVisitId: getViewVisitId(),
144
+ sectionId,
145
+ sectionType,
146
+ ...(sectionVariant ? { sectionVariant } : {}),
147
+ ...(objective ? { objective } : {}),
148
+ ...(context ? { context } : {}),
149
+ productHandle: String(product.id),
150
+ ...(product.href ? { destination: product.href } : {}),
151
+ })
152
+ } catch (error) {
153
+ reportFailure('noteClick', error)
154
+ }
155
+ }
156
+
157
+ return { containerRef, noteProductClick, enabled }
158
+ }
159
+
160
+ /**
161
+ * Observe a successfully rendered product destination. Recorded only when it
162
+ * matches the current section origin; otherwise it stays unattributed.
163
+ */
164
+ export function useProductVisitObservation(
165
+ productHandle: string | null | undefined,
166
+ destination?: string,
167
+ ): void {
168
+ const enabled = isSectionObservationEnabled()
169
+
170
+ useEffect(() => {
171
+ if (!enabled || !productHandle) return
172
+ try {
173
+ controller?.noteProductVisit({
174
+ viewVisitId: getViewVisitId(),
175
+ productHandle: String(productHandle),
176
+ ...(destination ? { destination } : {}),
177
+ })
178
+ } catch (error) {
179
+ reportFailure('noteProductVisit', error)
180
+ }
181
+ }, [enabled, productHandle, destination])
182
+ }
@@ -0,0 +1,130 @@
1
+ // Derived host helper: per-instance resolution for data-bound sections.
2
+ //
3
+ // A data-bound section (for example `products`) keeps its *selection* in a
4
+ // user-owned, per-instance config keyed by the section's stable id
5
+ // (`views/<view>/data.ts` -> `sectionSources`). This module is the single host
6
+ // boundary that turns that config into resolved, validated view state:
7
+ //
8
+ // * it validates each source with the canonical `sectionSourceSchema` (an
9
+ // invalid or missing source yields an `unconfigured` state, never a guess);
10
+ // * it resolves each selection through the existing canonical catalog bridge
11
+ // (`getCatalogResults`), so equal queries share request-local work, differing
12
+ // store/channel/region/currency contexts stay isolated, and unsupported
13
+ // criteria are reported rather than dropped;
14
+ // * it maps each canonical product to the shared presentation contract via the
15
+ // host mapper, so an unresolved price stays truthful instead of becoming $0;
16
+ // * it returns one discriminated state per section id, so a failing section
17
+ // never erases its siblings and a real failure is never replaced by mock data.
18
+ //
19
+ // Orphan sources (ids no longer referenced by the composition) are ignored.
20
+
21
+ import { getCatalogResults } from '@thorprovider/runtime/commerce'
22
+ import {
23
+ MERCHANDISING_SECTION_MAX_ITEMS,
24
+ sectionSourceSchema,
25
+ } from '@thorprovider/runtime/contracts'
26
+ import {
27
+ PRODUCT_IMAGE_FALLBACK,
28
+ isCompleteFiniteDecimal,
29
+ type ProductPresentation,
30
+ } from '@thorprovider/blocks'
31
+ import type { Product, StoreContext } from '@thorprovider/types'
32
+
33
+ /** Per-instance section selection config, keyed by the section's stable id. */
34
+ export type SectionSources = Record<string, unknown>
35
+
36
+ /**
37
+ * Canonical `Product` (unified provider output) → `ProductPresentation` for the
38
+ * merchandising product sections. Unlike the card mapper, this NEVER drops a
39
+ * product with an unresolved price: the canonical `Money` is passed through and
40
+ * the block renders a truthful "unavailable" state instead of inventing `$0`.
41
+ * Optional fields (image alt, rating, badge) are omitted — never `""` or a
42
+ * fabricated value — and a missing image falls back to the shared placeholder so
43
+ * no `<img>` is rendered without a `src`.
44
+ *
45
+ * This lives in the derived host helper (not the user-owned mappers module) so an
46
+ * upgraded home whose `mappers.ts` predates this capability still compiles.
47
+ */
48
+ export function toProductPresentation(product: Product): ProductPresentation {
49
+ const min = product.priceRange?.minVariantPrice
50
+ const compareAt = product.variants.find(
51
+ (variant) => variant.compareAtPrice && isCompleteFiniteDecimal(variant.compareAtPrice.amount),
52
+ )?.compareAtPrice
53
+ const image = product.featuredImage?.url || product.images[0]?.url || PRODUCT_IMAGE_FALLBACK
54
+ const imageAlt = product.featuredImage?.altText || undefined
55
+ return {
56
+ id: product.handle,
57
+ title: product.title,
58
+ image,
59
+ imageAlt,
60
+ href: `/products/${product.handle}`,
61
+ price: min ?? { amount: '', currencyCode: '' },
62
+ compareAtPrice: compareAt,
63
+ rating: undefined,
64
+ badge: undefined,
65
+ }
66
+ }
67
+
68
+ /** The resolved state the view state model carries for one data-bound section. */
69
+ export interface SectionViewState {
70
+ status:
71
+ | 'unconfigured'
72
+ | 'ready'
73
+ | 'empty'
74
+ | 'invalid-query'
75
+ | 'unsupported-query'
76
+ | 'unmounted-capability'
77
+ | 'data-error'
78
+ products: ProductPresentation[]
79
+ }
80
+
81
+ /**
82
+ * Read the optional `sectionSources` export from a user-owned data module
83
+ * without hard-importing it, so a home scaffolded before this capability (whose
84
+ * `data.ts` has no such export) still compiles and simply renders every
85
+ * data-bound section as `unconfigured`.
86
+ */
87
+ export function resolveSectionSources(module: unknown): SectionSources {
88
+ const candidate = (module as { sectionSources?: unknown } | null | undefined)?.sectionSources
89
+ return candidate && typeof candidate === 'object' ? (candidate as SectionSources) : {}
90
+ }
91
+
92
+ /**
93
+ * Resolve the state for every referenced data-bound section id. Sections resolve
94
+ * concurrently and independently; each entry is always present so the registry
95
+ * can render a truthful state for its section.
96
+ */
97
+ export async function resolveSectionViewStates(
98
+ ids: readonly string[],
99
+ sources: SectionSources,
100
+ context: StoreContext,
101
+ ): Promise<Record<string, SectionViewState>> {
102
+ const states: Record<string, SectionViewState> = {}
103
+ await Promise.all(
104
+ ids.map(async (id) => {
105
+ const parsed = sectionSourceSchema.safeParse(sources[id])
106
+ if (!parsed.success) {
107
+ states[id] = { status: 'unconfigured', products: [] }
108
+ return
109
+ }
110
+ const source = parsed.data
111
+ try {
112
+ const outcome = await getCatalogResults(source.query, context)
113
+ if (!outcome.ok) {
114
+ states[id] = { status: outcome.error.code, products: [] }
115
+ return
116
+ }
117
+ const maxItems = source.maxItems ?? MERCHANDISING_SECTION_MAX_ITEMS
118
+ const products = outcome.results.products
119
+ .slice(0, maxItems)
120
+ .map(toProductPresentation)
121
+ states[id] = { status: products.length > 0 ? 'ready' : 'empty', products }
122
+ } catch {
123
+ // A transport/provider failure is surfaced as a per-section data error,
124
+ // never as fabricated or mock products, and never discards siblings.
125
+ states[id] = { status: 'data-error', products: [] }
126
+ }
127
+ }),
128
+ )
129
+ return states
130
+ }
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Neutral defaults only: no delivery, shipping, or promo promise is made here,
8
8
  // and footer links point only at routes the generated storefront actually
9
- // serves (`/`, `/categorias`, `/checkout`, and the static pages `/contacto`,
10
- // `/sobre-nosotros`, `/terminos`, `/privacidad`). Brand colors and copy belong
9
+ // serves (`/`, `/categories`, `/checkout`, and the static pages `/contact`,
10
+ // `/about`, `/terms`, `/privacy`). Brand colors and copy belong
11
11
  // in your own content, never in the package.
12
12
  //
13
13
  // The scaffold ships no newsletter submit callback, so the footer newsletter is
@@ -67,23 +67,31 @@ export const footerClassName: string | undefined = undefined
67
67
  export const brandDescription: string =
68
68
  'Catálogo en línea de productos para tu hogar y tu negocio.'
69
69
 
70
+ /**
71
+ * Optional navbar utility-strip note (e.g. `'Nuevo horario de atención de 9 a
72
+ * 18 h'`). Empty by default: while it is empty and there are no utility links,
73
+ * the strip is not rendered, so a fresh storefront shows no note. Set a real
74
+ * note here to display it.
75
+ */
76
+ export const shippingNote: string = ''
77
+
70
78
  /** Neutral footer links — only routes the storefront serves; no dead `#` links. */
71
79
  export const linkColumns: ShellLinkColumn[] = [
72
80
  {
73
81
  title: 'Comprar',
74
82
  links: [
75
83
  { label: 'Inicio', href: '/' },
76
- { label: 'Todas las categorías', href: '/categorias' },
84
+ { label: 'Todas las categorías', href: '/categories' },
77
85
  { label: 'Finalizar compra', href: '/checkout' },
78
86
  ],
79
87
  },
80
88
  {
81
89
  title: 'Información',
82
90
  links: [
83
- { label: 'Contacto', href: '/contacto' },
84
- { label: 'Sobre nosotros', href: '/sobre-nosotros' },
85
- { label: 'Términos y condiciones', href: '/terminos' },
86
- { label: 'Política de privacidad', href: '/privacidad' },
91
+ { label: 'Contacto', href: '/contact' },
92
+ { label: 'Sobre nosotros', href: '/about' },
93
+ { label: 'Términos y condiciones', href: '/terms' },
94
+ { label: 'Política de privacidad', href: '/privacy' },
87
95
  ],
88
96
  },
89
97
  ]
@@ -94,3 +102,19 @@ export const linkColumns: ShellLinkColumn[] = [
94
102
  * renders no form — never a fake one.
95
103
  */
96
104
  export const newsletter: false | ShellNewsletter = false
105
+
106
+ // Primary destinations for the mobile bottom navigation. Only routes the
107
+ // storefront actually serves; the icons come from the shared category icon set.
108
+ export interface ShellBottomNavItem {
109
+ id?: string
110
+ label: string
111
+ href: string
112
+ icon?: string
113
+ }
114
+
115
+ export const mobileBottomNavItems: ShellBottomNavItem[] = [
116
+ { id: 'home', label: 'Inicio', href: '/', icon: 'home' },
117
+ { id: 'categories', label: 'Categorías', href: '/categories', icon: 'store' },
118
+ { id: 'cart', label: 'Carrito', href: '/cart', icon: 'boxes' },
119
+ { id: 'account', label: 'Cuenta', href: '/account', icon: 'shield' },
120
+ ]