@thorprovider/storefront 0.3.1 → 0.4.0-mvp.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +248 -99
  3. package/assets/templates/app/app/[[...slug]]/page.tsx +13 -2
  4. package/assets/templates/app/app/layout.tsx +18 -4
  5. package/assets/templates/app/app/robots.ts +13 -0
  6. package/assets/templates/app/app/sitemap.ts +12 -0
  7. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +542 -0
  8. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  9. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +40 -57
  10. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +608 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +165 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  13. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  14. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  15. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +232 -51
  16. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +3 -3
  17. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +121 -0
  18. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  20. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +96 -0
  21. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +34 -0
  22. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +100 -11
  23. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  24. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +26 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  27. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +142 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  30. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  31. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  32. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +17 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  34. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  39. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  40. package/assets/templates/features/checkout/handlers.ts +153 -0
  41. package/assets/templates/features/checkout/state.ts +61 -0
  42. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  43. package/assets/templates/features/customer-auth/state.ts +36 -0
  44. package/assets/templates/features/recommendations/state.ts +11 -0
  45. package/assets/templates/views/account/content.ts +35 -0
  46. package/assets/templates/views/account/index.tsx +114 -0
  47. package/assets/templates/views/account/registry.tsx +378 -0
  48. package/assets/templates/views/account/schema.ts +52 -0
  49. package/assets/templates/views/category/assembly/assembly.json +28 -0
  50. package/assets/templates/views/category/assembly/index.skeleton.tsx +193 -0
  51. package/assets/templates/views/category/assembly/registry.skeleton.tsx +33 -0
  52. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  53. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  54. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  55. package/assets/templates/views/category/content.ts +30 -0
  56. package/assets/templates/views/category/data.ts +12 -0
  57. package/assets/templates/views/category/index.tsx +230 -0
  58. package/assets/templates/views/category/props.ts +16 -0
  59. package/assets/templates/views/category/registry.tsx +103 -0
  60. package/assets/templates/views/category/schema.ts +78 -0
  61. package/assets/templates/views/checkout/content.ts +105 -0
  62. package/assets/templates/views/checkout/data.ts +23 -0
  63. package/assets/templates/views/checkout/index.tsx +111 -0
  64. package/assets/templates/views/checkout/props.ts +49 -0
  65. package/assets/templates/views/checkout/registry.tsx +217 -0
  66. package/assets/templates/views/checkout/schema.ts +229 -0
  67. package/assets/templates/views/home/assembly/assembly.json +24 -0
  68. package/assets/templates/views/home/assembly/index.skeleton.tsx +72 -0
  69. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  70. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  71. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  72. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  73. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  74. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  75. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  76. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  77. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  78. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  79. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  80. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  81. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  82. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  83. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  84. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  85. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  86. package/assets/templates/views/home/content.ts +70 -0
  87. package/assets/templates/views/home/data.ts +15 -0
  88. package/assets/templates/views/home/index.tsx +38 -84
  89. package/assets/templates/views/home/props.ts +22 -0
  90. package/assets/templates/views/home/registry.tsx +38 -22
  91. package/assets/templates/views/home/schema.ts +93 -0
  92. package/assets/templates/views/product/content.ts +25 -0
  93. package/assets/templates/views/product/data.ts +48 -0
  94. package/assets/templates/views/product/index.tsx +104 -0
  95. package/assets/templates/views/product/props.ts +20 -0
  96. package/assets/templates/views/product/registry.tsx +78 -0
  97. package/assets/templates/views/product/schema.ts +108 -0
  98. package/assets/templates/views/static/content.ts +99 -0
  99. package/assets/templates/views/static/index.tsx +60 -0
  100. package/assets/templates/views/static/registry.tsx +66 -0
  101. package/assets/templates/views/static/schema.ts +45 -0
  102. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/index.tsx +8 -70
  103. package/{skills/sf-view-gen/assets → assets}/templates/views/user-panel/registry.tsx +23 -18
  104. package/assets/templates/views/user-panel/schema.ts +79 -0
  105. package/bin/storefront.js +1278 -79
  106. package/commands/sf-add-view.md +44 -13
  107. package/commands/sf-feature.md +28 -0
  108. package/commands/sf-theme.md +14 -8
  109. package/commands/sf-view.md +54 -12
  110. package/package.json +6 -4
  111. package/recipes/archetypes.json +28 -1
  112. package/recipes/catalog.json +361 -0
  113. package/recipes/catalog.mjs +128 -0
  114. package/recipes/catalog.schema.json +64 -0
  115. package/recipes/feature.schema.json +89 -0
  116. package/recipes/features.json +139 -0
  117. package/recipes/manifest.schema.json +69 -0
  118. package/recipes/operations.schema.json +43 -0
  119. package/recipes/recipe.schema.json +11 -2
  120. package/recipes/recipes.json +60 -5
  121. package/recipes/sections.json +24 -5
  122. package/recipes/validate.mjs +784 -32
  123. package/scripts/candidate.mjs +236 -0
  124. package/scripts/composers.mjs +140 -0
  125. package/scripts/file-classes.mjs +116 -0
  126. package/scripts/logical-controls.mjs +61 -0
  127. package/scripts/manifest.mjs +298 -0
  128. package/scripts/operation-registry.mjs +256 -0
  129. package/scripts/operations.mjs +720 -0
  130. package/scripts/ownership-conflict.mjs +356 -0
  131. package/scripts/pinning.mjs +504 -0
  132. package/scripts/plan-apply.mjs +843 -0
  133. package/scripts/preflight.mjs +51 -0
  134. package/scripts/reconcile.mjs +511 -0
  135. package/scripts/render-templates.mjs +245 -28
  136. package/scripts/schema-validate.mjs +132 -0
  137. package/scripts/section-compat.mjs +300 -0
  138. package/scripts/section-operations.mjs +508 -0
  139. package/scripts/sync-assets.mjs +22 -15
  140. package/scripts/view-assembly.mjs +291 -0
  141. package/scripts/view-selection.mjs +211 -0
  142. package/skills/sf-feature/SKILL.md +67 -0
  143. package/skills/sf-theme-gen/SKILL.md +12 -5
  144. package/skills/sf-view-gen/SKILL.md +123 -93
  145. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -1
  146. package/skills/sf-view-gen/assets/recipes/catalog.json +361 -0
  147. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  148. package/skills/sf-view-gen/assets/recipes/features.json +139 -0
  149. package/skills/sf-view-gen/assets/recipes/recipes.json +60 -5
  150. package/skills/sf-view-gen/assets/recipes/sections.json +24 -5
  151. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  152. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  153. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  154. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  155. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  156. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  157. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  158. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  159. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  160. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  161. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  162. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  163. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  164. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  165. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  166. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  167. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  168. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  169. package/skills/sf-view-gen/references/views.md +44 -10
  170. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  171. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  172. package/skills/sf-view-gen/scripts/validate.mjs +784 -32
  173. package/skills/storefront-workflow/SKILL.md +116 -0
  174. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  175. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  176. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  177. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  178. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  179. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
@@ -0,0 +1,378 @@
1
+ 'use client'
2
+
3
+ // Thor Commerce view registry fragment: account
4
+ // Client boundary for the account view. Imports value bindings from
5
+ // @json-render/react and components from @thorprovider/blocks — both must stay
6
+ // on the client side of the RSC graph. Kept separate from index.tsx so the view
7
+ // spec (elements/catalog/route/loader) stays a pure Server Component module.
8
+ //
9
+ // Security invariant: the block-owned credential payload (email/password) is
10
+ // passed straight from the block callback to the Server Action and is NEVER
11
+ // written to the json-render state store, so no password reaches client state,
12
+ // HTML, or logs. Only the non-secret result (customer/error) is stored.
13
+
14
+ import { useEffect, useRef } from 'react'
15
+ import {
16
+ Button,
17
+ LoginPage1 as LoginBlock,
18
+ OrderHistory2,
19
+ SignUpPage1 as SignUpBlock,
20
+ } from '@thorprovider/blocks'
21
+ import { useStateStore } from '@json-render/react'
22
+ import type { Customer } from '@thorprovider/types'
23
+ import {
24
+ loadAccountAction,
25
+ loadOrdersAction,
26
+ loginAction,
27
+ logoutAction,
28
+ registerAction,
29
+ } from '../../actions'
30
+ import type { AccountOrderView } from '../../state.core'
31
+ import type { ViewRegistry } from '../types'
32
+ import {
33
+ accountLoginPropsSchema,
34
+ accountOrdersPropsSchema,
35
+ accountRegisterPropsSchema,
36
+ } from './schema'
37
+
38
+ // The structured result the auth Server Actions return (see ../../actions).
39
+ type AuthResult = { ok: true; customer: Customer | null } | { ok: false; error: string }
40
+ type SetState = (path: string, value: unknown) => void
41
+
42
+ // Apply a settled auth result truthfully: a failure sets an error and NEVER a
43
+ // logged-in state; a success stores the customer (or null) and nothing else.
44
+ function applyAuthResult(set: SetState, result: AuthResult): void {
45
+ set('/accountPending', false)
46
+ if (result.ok) {
47
+ set('/accountCustomer', result.customer)
48
+ set('/accountAuthenticated', result.customer !== null)
49
+ set('/accountError', null)
50
+ set('/accountMode', 'login')
51
+ return
52
+ }
53
+ set('/accountError', result.error)
54
+ }
55
+
56
+ // A rejected Server Action (network/RPC) must clear pending and surface a
57
+ // message; never swallow it. Only a non-secret context line is logged.
58
+ function authFailure(set: SetState, scope: string) {
59
+ return (error: unknown) => {
60
+ console.error(
61
+ `[storefront/account] ${scope} failed`,
62
+ error instanceof Error ? error.message : error,
63
+ )
64
+ set('/accountPending', false)
65
+ set('/accountError', 'No se pudo completar la operación. Inténtalo de nuevo.')
66
+ }
67
+ }
68
+
69
+ // Non-secret status surface for the block pages, which expose no error/pending
70
+ // prop of their own. Renders nothing when there is no pending work or error.
71
+ function AuthNotice({ pending, error }: { pending: boolean; error: string | null }) {
72
+ if (!pending && !error) return null
73
+ return (
74
+ <div
75
+ role="alert"
76
+ aria-live="polite"
77
+ className="bg-background text-foreground fixed inset-x-0 top-4 z-50 mx-auto w-fit max-w-md rounded-md border px-4 py-2 text-center text-sm shadow-lg"
78
+ >
79
+ {pending ? 'Procesando…' : error}
80
+ </div>
81
+ )
82
+ }
83
+
84
+ // The OrderHistory2 block's order shape (see @thorprovider/blocks). Kept local
85
+ // so the registry maps the account order rows to the block contract without a
86
+ // shared dependency on block internals.
87
+ interface BlockOrderItem {
88
+ id: string
89
+ name: string
90
+ image: string
91
+ details: string
92
+ price: string
93
+ quantity: number
94
+ }
95
+ interface BlockOrder {
96
+ id: string
97
+ date: string
98
+ status: string
99
+ total: string
100
+ items: BlockOrderItem[]
101
+ }
102
+
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
+ function toBlockOrder(order: AccountOrderView): BlockOrder {
107
+ return {
108
+ id: order.number,
109
+ date: order.date,
110
+ status: order.status,
111
+ total: order.total,
112
+ items: order.items.map((item) => ({
113
+ id: item.id,
114
+ name: item.name,
115
+ image: item.image ?? '',
116
+ details: item.variant ?? '',
117
+ price: item.lineTotal,
118
+ quantity: item.quantity,
119
+ })),
120
+ }
121
+ }
122
+
123
+ // Inline order detail assembled from the already-fetched list row (no second
124
+ // backend request). Only fields the backend supplied are shown.
125
+ function OrderDetail({ order }: { order: AccountOrderView }) {
126
+ const totals = (
127
+ [
128
+ { label: 'Subtotal', value: order.subtotal },
129
+ { label: 'Impuestos', value: order.tax },
130
+ { label: 'Envío', value: order.shipping },
131
+ { label: 'Total', value: order.total },
132
+ ] as { label: string; value?: string }[]
133
+ ).filter((row): row is { label: string; value: string } => row.value !== undefined)
134
+ return (
135
+ <div className="mx-auto w-full max-w-7xl px-4 pb-12 sm:px-6 lg:px-8">
136
+ <div className="rounded-lg border p-6">
137
+ <h3 className="text-lg font-semibold">Pedido {order.number}</h3>
138
+ <p className="text-muted-foreground mt-1 text-sm">
139
+ {order.date} · {order.status}
140
+ </p>
141
+ {order.address ? (
142
+ <p className="text-muted-foreground mt-2 text-sm">{order.address}</p>
143
+ ) : null}
144
+ <ul className="mt-4 divide-y">
145
+ {order.items.map((item) => (
146
+ <li key={item.id} className="flex items-center justify-between py-2 text-sm">
147
+ <span>
148
+ {item.name}
149
+ {item.variant ? (
150
+ <span className="text-muted-foreground"> · {item.variant}</span>
151
+ ) : null}
152
+ <span className="text-muted-foreground"> × {item.quantity}</span>
153
+ </span>
154
+ <span>{item.lineTotal}</span>
155
+ </li>
156
+ ))}
157
+ </ul>
158
+ <dl className="mt-4 space-y-1 border-t pt-4 text-sm">
159
+ {totals.map((row) => (
160
+ <div key={row.label} className="flex justify-between">
161
+ <dt className="text-muted-foreground">{row.label}</dt>
162
+ <dd>{row.value}</dd>
163
+ </div>
164
+ ))}
165
+ </dl>
166
+ </div>
167
+ </div>
168
+ )
169
+ }
170
+
171
+ export const registry: ViewRegistry = {
172
+ /**
173
+ * Headless bootstrap rendered on the account route. It resolves the current
174
+ * customer from the cookie-authorized Server Action once, so a reload keeps
175
+ * the signed-in state without re-entering credentials. It never reads a token.
176
+ */
177
+ AccountBoot: () => {
178
+ const { set } = useStateStore()
179
+ const booted = useRef(false)
180
+ useEffect(() => {
181
+ if (booted.current) return
182
+ booted.current = true
183
+ void loadAccountAction()
184
+ .then((result) => {
185
+ if (!result.ok) {
186
+ set('/accountCustomer', null)
187
+ set('/accountAuthenticated', false)
188
+ set('/accountError', result.error)
189
+ return
190
+ }
191
+ set('/accountCustomer', result.customer)
192
+ set('/accountAuthenticated', result.customer !== null)
193
+ set('/accountError', null)
194
+ })
195
+ .catch((error) => {
196
+ console.error(
197
+ '[storefront/account] loadAccount failed',
198
+ error instanceof Error ? error.message : error,
199
+ )
200
+ set('/accountCustomer', null)
201
+ set('/accountAuthenticated', false)
202
+ set('/accountError', 'No se pudo cargar la cuenta. Inténtalo de nuevo.')
203
+ })
204
+ }, [])
205
+ return null
206
+ },
207
+ AccountLogin: ({ props, emit }) => {
208
+ const { state, set } = useStateStore()
209
+ const parsed = accountLoginPropsSchema.parse(props)
210
+ const error = typeof state.accountError === 'string' ? state.accountError : null
211
+ const pending = Boolean(state.accountPending)
212
+ return (
213
+ <>
214
+ <AuthNotice pending={pending} error={error} />
215
+ <LoginBlock
216
+ logoText={parsed.logoText}
217
+ welcomeTitleLabel={parsed.welcomeTitleLabel}
218
+ welcomeDescriptionLabel={parsed.welcomeDescriptionLabel}
219
+ emailLabel={parsed.emailLabel}
220
+ passwordLabel={parsed.passwordLabel}
221
+ signInLabel={parsed.signInLabel}
222
+ createAccountLabel={parsed.createAccountLabel}
223
+ // The credentials stay in the block's local state and go straight to
224
+ // the Server Action; they are never written to the store.
225
+ onSubmit={(data) => {
226
+ set('/accountPending', true)
227
+ set('/accountError', null)
228
+ void loginAction({ email: data.email, password: data.password })
229
+ .then((result) => applyAuthResult(set, result))
230
+ .catch(authFailure(set, 'login'))
231
+ }}
232
+ onCreateAccount={() => emit('showRegister')}
233
+ />
234
+ </>
235
+ )
236
+ },
237
+ AccountRegister: ({ props, emit }) => {
238
+ const { state, set } = useStateStore()
239
+ const parsed = accountRegisterPropsSchema.parse(props)
240
+ const error = typeof state.accountError === 'string' ? state.accountError : null
241
+ const pending = Boolean(state.accountPending)
242
+ return (
243
+ <>
244
+ <AuthNotice pending={pending} error={error} />
245
+ <SignUpBlock
246
+ logoText={parsed.logoText}
247
+ createAccountTitleLabel={parsed.createAccountTitleLabel}
248
+ createAccountDescriptionLabel={parsed.createAccountDescriptionLabel}
249
+ fullNameLabel={parsed.fullNameLabel}
250
+ emailLabel={parsed.emailLabel}
251
+ passwordLabel={parsed.passwordLabel}
252
+ createAccountLabel={parsed.createAccountLabel}
253
+ signInLabel={parsed.signInLabel}
254
+ onSubmit={(data) => {
255
+ const parts = data.fullName.trim().split(/\s+/).filter(Boolean)
256
+ set('/accountPending', true)
257
+ set('/accountError', null)
258
+ void registerAction({
259
+ email: data.email,
260
+ password: data.password,
261
+ first_name: parts[0] ?? '',
262
+ last_name: parts.slice(1).join(' '),
263
+ })
264
+ .then((result) => applyAuthResult(set, result))
265
+ .catch(authFailure(set, 'register'))
266
+ }}
267
+ onSignIn={() => emit('showLogin')}
268
+ />
269
+ </>
270
+ )
271
+ },
272
+ AccountPanel: ({ emit }) => {
273
+ const { state } = useStateStore()
274
+ const customer = (state.accountCustomer ?? null) as Customer | null
275
+ const error = typeof state.accountError === 'string' ? state.accountError : null
276
+ const pending = Boolean(state.accountPending)
277
+ const name = customer
278
+ ? [customer.firstName, customer.lastName].filter(Boolean).join(' ') || customer.email
279
+ : ''
280
+ return (
281
+ <div className="mx-auto max-w-md px-4 py-16 text-center">
282
+ <h1 className="text-2xl font-bold">Mi cuenta</h1>
283
+ <p className="text-muted-foreground mt-2 text-sm">Has iniciado sesión como</p>
284
+ <p className="mt-1 text-lg font-medium">{name}</p>
285
+ {customer?.email ? <p className="text-muted-foreground text-sm">{customer.email}</p> : null}
286
+ {error ? (
287
+ <p role="alert" className="text-destructive mt-4 text-sm">
288
+ {error}
289
+ </p>
290
+ ) : null}
291
+ <Button className="mt-6" disabled={pending} onClick={() => emit('logout')}>
292
+ {pending ? 'Cerrando sesión…' : 'Cerrar sesión'}
293
+ </Button>
294
+ </div>
295
+ )
296
+ },
297
+ /**
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
+ */
304
+ AccountOrders: ({ props }) => {
305
+ const { state, set } = useStateStore()
306
+ const parsed = accountOrdersPropsSchema.parse(props)
307
+ const loaded = useRef(false)
308
+ useEffect(() => {
309
+ if (loaded.current) return
310
+ loaded.current = true
311
+ set('/accountOrdersPending', true)
312
+ set('/accountOrdersError', null)
313
+ void loadOrdersAction()
314
+ .then((result) => {
315
+ set('/accountOrdersPending', false)
316
+ if (result.ok) {
317
+ set('/accountOrders', result.orders)
318
+ set('/accountOrdersError', null)
319
+ return
320
+ }
321
+ set('/accountOrdersError', result.error)
322
+ })
323
+ .catch((error) => {
324
+ console.error(
325
+ '[storefront/account] loadOrders failed',
326
+ error instanceof Error ? error.message : error,
327
+ )
328
+ set('/accountOrdersPending', false)
329
+ set('/accountOrdersError', parsed.errorLabel ?? 'No se pudieron cargar tus pedidos.')
330
+ })
331
+ // eslint-disable-next-line react-hooks/exhaustive-deps
332
+ }, [])
333
+
334
+ const pending = Boolean(state.accountOrdersPending)
335
+ const error = typeof state.accountOrdersError === 'string' ? state.accountOrdersError : null
336
+ const orders = Array.isArray(state.accountOrders)
337
+ ? (state.accountOrders as AccountOrderView[])
338
+ : []
339
+ const selectedId =
340
+ typeof state.accountSelectedOrderId === 'string' ? state.accountSelectedOrderId : null
341
+ const selected = selectedId
342
+ ? orders.find((order) => order.number === selectedId) ?? null
343
+ : null
344
+
345
+ if (pending) {
346
+ return (
347
+ <p className="text-muted-foreground mx-auto max-w-7xl px-4 py-8 text-sm">
348
+ {parsed.loadingLabel ?? 'Cargando pedidos…'}
349
+ </p>
350
+ )
351
+ }
352
+ if (error) {
353
+ return (
354
+ <p role="alert" className="text-destructive mx-auto max-w-7xl px-4 py-8 text-sm">
355
+ {error}
356
+ </p>
357
+ )
358
+ }
359
+ if (orders.length === 0) {
360
+ return (
361
+ <p className="text-muted-foreground mx-auto max-w-7xl px-4 py-8 text-sm">
362
+ {parsed.emptyLabel ?? 'Aún no tienes pedidos.'}
363
+ </p>
364
+ )
365
+ }
366
+ return (
367
+ <>
368
+ <OrderHistory2
369
+ orders={orders.map(toBlockOrder)}
370
+ titleLabel={parsed.titleLabel}
371
+ descriptionLabel={parsed.descriptionLabel}
372
+ onViewDetails={(order) => set('/accountSelectedOrderId', order.id)}
373
+ />
374
+ {selected ? <OrderDetail order={selected} /> : null}
375
+ </>
376
+ )
377
+ },
378
+ }
@@ -0,0 +1,52 @@
1
+ // Thor Commerce view schema fragment: account
2
+ // Zod mirrors of the block contracts for this view — the single source for the
3
+ // catalog entries and the client-side registry parsing (json-render does not
4
+ // validate props against the catalog itself, so the registry parses here).
5
+ // Kept free of React and server imports so the Server Component module and the
6
+ // 'use client' registry can share one contract. 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
+ // Mirrors the LoginPage1 optional props. Every prop is optional with a neutral
13
+ // default in the block, so the curated view forwards only the labels it owns.
14
+ export const accountLoginPropsSchema = z.object({
15
+ logoText: z.string().optional(),
16
+ welcomeTitleLabel: z.string().optional(),
17
+ welcomeDescriptionLabel: z.string().optional(),
18
+ emailLabel: z.string().optional(),
19
+ passwordLabel: z.string().optional(),
20
+ signInLabel: z.string().optional(),
21
+ createAccountLabel: z.string().optional(),
22
+ })
23
+
24
+ // Mirrors the SignUpPage1 optional props.
25
+ export const accountRegisterPropsSchema = z.object({
26
+ logoText: z.string().optional(),
27
+ createAccountTitleLabel: z.string().optional(),
28
+ createAccountDescriptionLabel: z.string().optional(),
29
+ fullNameLabel: z.string().optional(),
30
+ emailLabel: z.string().optional(),
31
+ passwordLabel: z.string().optional(),
32
+ createAccountLabel: z.string().optional(),
33
+ signInLabel: z.string().optional(),
34
+ })
35
+
36
+ // Mirrors the OrderHistory2 label props the curated view forwards, plus the
37
+ // explicit loading/empty/error copy the registry renders for the orders states.
38
+ export const accountOrdersPropsSchema = z.object({
39
+ titleLabel: z.string().optional(),
40
+ descriptionLabel: z.string().optional(),
41
+ emptyLabel: z.string().optional(),
42
+ errorLabel: z.string().optional(),
43
+ loadingLabel: z.string().optional(),
44
+ })
45
+
46
+ // The headless bootstrap and the signed-in panel read their data from the
47
+ // feature state, not from element props; declared so the catalog is complete.
48
+ export const accountEmptyPropsSchema = z.object({})
49
+
50
+ export type AccountLoginProps = z.infer<typeof accountLoginPropsSchema>
51
+ export type AccountRegisterProps = z.infer<typeof accountRegisterPropsSchema>
52
+ export type AccountOrdersProps = z.infer<typeof accountOrdersPropsSchema>
@@ -0,0 +1,28 @@
1
+ {
2
+ "view": "category",
3
+ "slots": [
4
+ { "id": "category.filter", "slot": "filter", "default": "CategoryFilter2" },
5
+ {
6
+ "id": "category.productCard",
7
+ "slot": "productCard",
8
+ "default": "ProductCard1",
9
+ "nested": true
10
+ }
11
+ ],
12
+ "sections": {
13
+ "faq": {
14
+ "elementType": "Faq",
15
+ "registryModule": "../sections/faq.client",
16
+ "registryExport": "faqRegistry",
17
+ "propsModule": "./content",
18
+ "defaultProps": "{ items: [] }"
19
+ },
20
+ "cta": {
21
+ "elementType": "Cta",
22
+ "registryModule": "../sections/cta.client",
23
+ "registryExport": "ctaRegistry",
24
+ "propsModule": "./content",
25
+ "defaultProps": "{}"
26
+ }
27
+ }
28
+ }
@@ -0,0 +1,193 @@
1
+ // Thor Commerce view module: category
2
+ // Server Component module: elements + catalog entries + route + loader only.
3
+ // The client-side component mappings live in ./registry.tsx ('use client'); this
4
+ // module imports them as data so the RSC graph never evaluates @json-render/react
5
+ // on the server. Registered by importing it in ../index.ts. Recipe: recipes.json -> "category".
6
+ //
7
+ // E2 task 6.1: the category listing is ONE controlled `CategoryFilter2` — the
8
+ // base listing slot `category.filter`, always present. The optional
9
+ // `catalog-search` / `category-filters` features are control contributions to
10
+ // that single block (logical mounts `category.search` / `category.filters`),
11
+ // never a second listing/controller. Their visibility and config are read from
12
+ // the installed manifest (`mountedFeatures` / `mountedFeatureConfigs`) that
13
+ // `sync` derives from `storefront.config.json`; no loader is hand-edited to add
14
+ // or remove them.
15
+
16
+ import { z } from 'zod'
17
+ import {
18
+ buildActiveFilterChips,
19
+ getCatalog,
20
+ getCatalogSortKeys,
21
+ getCollectionOptions,
22
+ loadCategoryListing,
23
+ mapCatalogOutcomeToStatus,
24
+ mapCatalogResultsToFilterProps,
25
+ } from '@thorprovider/runtime/commerce'
26
+ import { getDataSource, getStoreContext, isRealMode } from '../../store-context'
27
+ import { mountedFeatures, mountedFeatureConfigs } from '../../composition'
28
+ import { pageRoot } from '../../spec/page-root'
29
+ import type { StorefrontElement } from '../../spec/types'
30
+ import type { ViewModule } from '../types'
31
+ import { registry } from './registry'
32
+ import { categoryFilterProps } from './props'
33
+ import { categoryFilterPropsSchema } from './schema'@@SECTION_IMPORTS@@
34
+
35
+ // The single controlled `CategoryFilter2` base listing. The search/filter
36
+ // controls are shown only when the matching feature is installed; the base
37
+ // listing always owns the route scope, `sort`, `page`, `pageSize`, the products,
38
+ // and pagination.
39
+ const hasSearch = mountedFeatures.includes('catalog-search')
40
+ const hasFilters = mountedFeatures.includes('category-filters')
41
+ const searchConfig = (mountedFeatureConfigs['catalog-search'] ?? {}) as { placeholder?: string }
42
+ const filtersConfig = (mountedFeatureConfigs['category-filters'] ?? {}) as {
43
+ filterSet?: string[]
44
+ showPrice?: boolean
45
+ }
46
+
47
+ // UI labels for the adapter-advertised sort keys. The keys themselves come from
48
+ // the provider (`getCatalogSortKeys`); no global price sort is ever assumed.
49
+ const SORT_LABELS: Record<string, string> = {
50
+ featured: 'Destacados',
51
+ name: 'Nombre',
52
+ newest: 'Más recientes',
53
+ }
54
+
55
+ const elements: Record<string, StorefrontElement> = {
56
+ @@ELEMENTS@@
57
+ }
58
+
59
+ const catalog = {
60
+ @@CATALOG@@
61
+ }
62
+
63
+ /** Empty state for the single listing, used on every failure path. */
64
+ const emptyFilterState = () => ({
65
+ categoryFilterProducts: [],
66
+ categoryFilterPagination: [] as (number | string)[],
67
+ categoryFilterActiveFilters: [],
68
+ categoryFilterSearchValue: '',
69
+ categoryFilterSelectedCategories: [] as string[],
70
+ categoryFilterSelectedCollections: [] as string[],
71
+ categoryFilterSelectedSort: 'featured',
72
+ categoryFilterSelectedPage: 1,
73
+ categoryFilterSelectedPageSize: 50,
74
+ categoryFilterSortOptions: [],
75
+ categoryFilterCategories: [],
76
+ categoryFilterCollections: [],
77
+ categoryFilterPriceRange: undefined,
78
+ })
79
+
80
+ export const category: ViewModule = {
81
+ name: 'category',
82
+ elements,
83
+ catalog,
84
+ registry,
85
+ route: {
86
+ path: '/categorias/[[...slug]]',
87
+ loaderName: 'category',
88
+ layout: 'default',
89
+ metadata: {
90
+ title: 'Categoría',
91
+ description: 'Explora los productos de esta categoría en __SITE_NAME__.',
92
+ },
93
+ entry: pageRoot('category', [@@SLOT_IDS@@], elements),
94
+ },
95
+ loader: {
96
+ category: async (params, searchParams) => {
97
+ const slug = Array.isArray(params.slug) ? params.slug[0] : params.slug
98
+ try {
99
+ // Validate the raw URL through the pure boundary before any commerce
100
+ // call. An invalid, repeated, or unknown parameter — or one owned by a
101
+ // feature that is not installed — returns a typed error with zero
102
+ // provider resolution, not even the sidebar catalog (E2 task 5.1/6.1).
103
+ const listing = await loadCategoryListing({
104
+ searchParams: searchParams ?? {},
105
+ ...(slug ? { categoryScope: slug } : {}),
106
+ mountedFeatures,
107
+ ...(isRealMode() ? { context: getStoreContext() } : {}),
108
+ })
109
+
110
+ if (!listing.ok) {
111
+ return {
112
+ storeCategories: [],
113
+ dataSource: getDataSource(),
114
+ dataError: null,
115
+ ...emptyFilterState(),
116
+ categoryFilterStatus: mapCatalogOutcomeToStatus({ ok: false, error: listing.error }),
117
+ }
118
+ }
119
+
120
+ const catalog = await getCatalog()
121
+ const advertisedSortKeys = await getCatalogSortKeys()
122
+ const sortOptions = advertisedSortKeys.map((key) => ({
123
+ value: key,
124
+ label: SORT_LABELS[key] ?? key,
125
+ }))
126
+ // Real adapter category options (handle + name). No fake counts/ranges.
127
+ const allCategories = catalog.categoriesRaw.map((entry) => ({
128
+ id: entry.handle,
129
+ label: entry.name,
130
+ }))
131
+ const filterSet = filtersConfig.filterSet ?? ['categories', 'collections']
132
+ const categories = filterSet.includes('categories') ? allCategories : undefined
133
+ // Real, backend-scoped collection options (handle + title), fetched only
134
+ // when `category-filters` is installed and owns the collections control.
135
+ // An empty backend list omits the control; no option is ever fabricated.
136
+ const showCollections = hasFilters && filterSet.includes('collections')
137
+ const allCollections = showCollections
138
+ ? (await getCollectionOptions()).map((entry) => ({
139
+ id: entry.id,
140
+ label: entry.label,
141
+ }))
142
+ : []
143
+ const collections = allCollections.length > 0 ? allCollections : undefined
144
+ const presentation = mapCatalogResultsToFilterProps({
145
+ query: listing.query,
146
+ results: listing.results,
147
+ // A scoped listing (`/categorias/<handle>`) must not offer the
148
+ // category facet: the scope already fixes the category. The root
149
+ // catalog passes no scope and keeps it.
150
+ ...(slug ? { categoryScope: slug } : {}),
151
+ // Product links point at the product-detail route. The runtime stays
152
+ // route-agnostic, so this view supplies the href builder.
153
+ productHref: (handle: string) => `/productos/${handle}`,
154
+ ...(categories ? { categories } : {}),
155
+ ...(collections ? { collections } : {}),
156
+ sortOptions,
157
+ })
158
+ const activeFilters = buildActiveFilterChips(listing.query, {
159
+ categories: Object.fromEntries(allCategories.map((entry) => [entry.id, entry.label])),
160
+ collections: Object.fromEntries(allCollections.map((entry) => [entry.id, entry.label])),
161
+ })
162
+
163
+ return {
164
+ storeCategories: catalog.categories,
165
+ dataSource: catalog.source,
166
+ dataError: null,
167
+ categoryFilterProducts: presentation.products,
168
+ categoryFilterPagination: presentation.pagination,
169
+ categoryFilterStatus: mapCatalogOutcomeToStatus({ ok: true, results: listing.results }),
170
+ categoryFilterActiveFilters: activeFilters,
171
+ categoryFilterSearchValue: presentation.searchValue,
172
+ categoryFilterSelectedCategories: presentation.selectedCategories,
173
+ categoryFilterSelectedCollections: presentation.selectedCollections,
174
+ categoryFilterSelectedSort: presentation.selectedSort,
175
+ categoryFilterSelectedPage: presentation.selectedPage,
176
+ categoryFilterSelectedPageSize: presentation.selectedPageSize,
177
+ categoryFilterSortOptions: presentation.sortOptions ?? [],
178
+ categoryFilterCategories: presentation.categories ?? [],
179
+ categoryFilterCollections: presentation.collections ?? [],
180
+ categoryFilterPriceRange: presentation.priceRange,
181
+ }
182
+ } catch (error) {
183
+ return {
184
+ storeCategories: [],
185
+ dataSource: getDataSource(),
186
+ dataError: error instanceof Error ? error.message : 'No se pudo cargar la categoría.',
187
+ ...emptyFilterState(),
188
+ categoryFilterStatus: { state: 'error' as const },
189
+ }
190
+ }
191
+ },
192
+ },
193
+ }
@@ -0,0 +1,33 @@
1
+ 'use client'
2
+
3
+ // Thor Commerce view registry fragment: category
4
+ // The category view's single client boundary: one controlled `CategoryFilter2`
5
+ // (the base listing slot `category.filter`). Search/filters/sort/pagination are
6
+ // URL-backed: the block emits a change, this registry rewrites the URL through
7
+ // the pure `buildCategoryQueryHref` seam, and the server loader re-seeds the
8
+ // controlled props from the new URL. No fetch and no provider call happens on
9
+ // the client. The search and filter controls are only present because the
10
+ // installed manifest enabled them; the block never adds a second listing.
11
+
12
+ import {
13
+ CategoryFilter2 as CategoryFilter2Block,
14
+ type CategoryFilter2ActiveFilter,
15
+ type CategoryFilter2Filters,
16
+ type CategoryFilter2Props,
17
+ } from '@thorprovider/blocks'@@BLOCK_IMPORTS@@@@SECTION_REGISTRY_IMPORTS@@
18
+ import { buildCategoryQueryHref, catalogHistoryMode, type CategoryQueryChange } from '@thorprovider/runtime/commerce'
19
+ import type { CatalogFeatureName } from '@thorprovider/runtime/contracts'
20
+ import { useStateStore } from '@json-render/react'
21
+ import { usePathname, useRouter } from 'next/navigation'
22
+ import type { ViewRegistry } from '../types'
23
+
24
+ // The generated view module injects the installed-feature context as an element
25
+ // prop (task 4.11); it is composition context, not a `CategoryFilter2` prop, so
26
+ // it is read separately from the block props.
27
+ type CategoryFilter2RegistryProps = CategoryFilter2Props & {
28
+ mountedFeatures?: readonly CatalogFeatureName[]
29
+ }
30
+
31
+ export const registry: ViewRegistry = {
32
+ @@REGISTRY@@
33
+ }