@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,542 @@
1
+ 'use server'
2
+
3
+ // Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/actions.ts)
4
+ //
5
+ // Host Server Actions — the only place the generated storefront mutates the
6
+ // backend cart and the session cookie. Next.js 16 `cookies()` may only be
7
+ // written from a Server Action or Route Handler, so the guest cart identity and
8
+ // the completed-session cookie are created and updated here, never during a
9
+ // render.
10
+ //
11
+ // Every action resolves and validates the store context before the commerce
12
+ // call. Cart mutations use the backend variant id for adds and the backend
13
+ // line-item id for updates/removals; the Navbar's DELTA is normalized against
14
+ // the authoritative cart line quantity. Completion is discriminated and never
15
+ // blindly retried; reconciliation uses the active cart id when no completed
16
+ // receipt cookie exists yet.
17
+
18
+ import type { AccountOrderView, CheckoutFieldErrors, CheckoutShippingOption } from './state.core'
19
+ import type { Customer, PaymentMethod } from '@thorprovider/types'
20
+ import {
21
+ EMPTY_CART_STATE,
22
+ addVariant,
23
+ applyLineDelta,
24
+ applyPayment,
25
+ authenticate,
26
+ completeOrder,
27
+ createGuestCart,
28
+ endSession,
29
+ loadCheckoutCountries,
30
+ loadCustomer,
31
+ loadOrders,
32
+ loadPaymentMethods,
33
+ loadShippingMethods,
34
+ readCart,
35
+ reconcileOrder,
36
+ registerShopper,
37
+ removeLine,
38
+ saveShippingAddress,
39
+ selectShipping,
40
+ setLineQuantity,
41
+ toCheckoutFieldErrors,
42
+ toCheckoutShippingOptions,
43
+ validateAddress,
44
+ type AddressInput,
45
+ type CartStatePatch,
46
+ type PlaceOrderResult,
47
+ } from './commerce'
48
+ import { getStoreContext } from './store-context'
49
+ import {
50
+ bindHostSessionCart,
51
+ clearAuthToken,
52
+ completeHostSessionCart,
53
+ readAuthToken,
54
+ readHostSession,
55
+ writeAuthToken,
56
+ } from './session'
57
+
58
+ // A `'use server'` module may export ONLY async functions. Next/Turbopack's
59
+ // server-actions transform registers every export as a runtime server
60
+ // reference, so a type export (even `export type`) is emitted as a value and
61
+ // crashes at runtime (`X is not defined`). The result types below are local;
62
+ // the shared shapes are imported from `./commerce` and never re-exported here.
63
+
64
+ /** Structured result for a cart-mutating action. */
65
+ type CartActionResult =
66
+ | { ok: true; cart: CartStatePatch }
67
+ | { ok: false; error: string }
68
+
69
+ /** Structured result for a checkout action. */
70
+ type CheckoutActionResult =
71
+ | {
72
+ ok: true
73
+ cart: CartStatePatch
74
+ countries?: { value: string; label: string }[]
75
+ paymentMethods?: PaymentMethod[]
76
+ shippingOptions?: CheckoutShippingOption[]
77
+ fieldErrors?: CheckoutFieldErrors
78
+ }
79
+ | { ok: false; error: string; fieldErrors?: CheckoutFieldErrors }
80
+
81
+ /** Structured result for an auth action that may carry the current customer. */
82
+ type AuthActionResult =
83
+ | { ok: true; customer: Customer | null }
84
+ | { ok: false; error: string }
85
+
86
+ /** Structured result for logout; the authoritative cookie clear is the outcome. */
87
+ type LogoutActionResult = { ok: true } | { ok: false; error: string }
88
+
89
+ /** Structured result for the customer order-history load. */
90
+ type OrdersActionResult =
91
+ | { ok: true; orders: AccountOrderView[] }
92
+ | { ok: false; error: string }
93
+
94
+ /** Basic email shape check; a boundary guard, not a full RFC validation. */
95
+ const EMAIL_PATTERN = /\S+@\S+\.\S+/
96
+
97
+ /** Log a non-secret context line for a caught server error (never a secret). */
98
+ function logServerError(scope: string, error: unknown): void {
99
+ const message = error instanceof Error ? error.message : String(error)
100
+ const code =
101
+ error && typeof error === 'object' && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
102
+ ? (error as { code: string }).code
103
+ : undefined
104
+ console.error(`[storefront/actions] ${scope} failed`, { message, code })
105
+ }
106
+
107
+ function toError(error: unknown, fallback: string): string {
108
+ return error instanceof Error && error.message.trim() !== '' ? error.message : fallback
109
+ }
110
+
111
+ /**
112
+ * Resolve the active cart id, creating and binding a new backend cart when the
113
+ * session has none or its cart was completed. A new cart after a completed one
114
+ * keeps the SAME shopper and preserves the prior receipt cart id, so the
115
+ * receipt proof cookie is never lost.
116
+ */
117
+ async function ensureActiveCartId(): Promise<{ cartId: string; context: ReturnType<typeof getStoreContext> }> {
118
+ const context = getStoreContext()
119
+ const session = await readHostSession(context)
120
+ if (session?.cartId) {
121
+ const existing = await readCart(session.cartId, context)
122
+ if (existing && !existing.cartCompleted) return { cartId: session.cartId, context }
123
+ }
124
+ const created = await createGuestCart(context)
125
+ if (!created.cartId) throw new Error('Cart creation returned no id')
126
+ await bindHostSessionCart(context, created.cartId)
127
+ return { cartId: created.cartId, context }
128
+ }
129
+
130
+ /** Ensure a guest cart exists and return its authoritative snapshot. */
131
+ export async function ensureCartAction(): Promise<CartActionResult> {
132
+ try {
133
+ const { cartId, context } = await ensureActiveCartId()
134
+ return { ok: true, cart: (await readCart(cartId, context)) ?? EMPTY_CART_STATE }
135
+ } catch (error) {
136
+ return { ok: false, error: toError(error, 'No se pudo iniciar el carrito.') }
137
+ }
138
+ }
139
+
140
+ /** Add the explicitly selected backend variant id at the requested quantity. */
141
+ export async function addToCartAction(input: {
142
+ variantId: string
143
+ quantity: number
144
+ }): Promise<CartActionResult> {
145
+ try {
146
+ if (!input?.variantId) return { ok: false, error: 'Selecciona una variante antes de agregar.' }
147
+ const quantity = Number.isFinite(input.quantity) && input.quantity > 0 ? Math.floor(input.quantity) : 1
148
+ const { cartId, context } = await ensureActiveCartId()
149
+ return { ok: true, cart: await addVariant(cartId, input.variantId, quantity, context) }
150
+ } catch (error) {
151
+ return { ok: false, error: toError(error, 'No se pudo agregar el producto.') }
152
+ }
153
+ }
154
+
155
+ /** Set a backend cart line to an ABSOLUTE quantity by its line-item id. */
156
+ export async function setLineQuantityAction(input: {
157
+ lineId: string
158
+ merchandiseId: string
159
+ quantity: number
160
+ }): Promise<CartActionResult> {
161
+ try {
162
+ const quantity = Math.max(1, Math.floor(input.quantity))
163
+ const { cartId, context } = await ensureActiveCartId()
164
+ return {
165
+ ok: true,
166
+ cart: await setLineQuantity(cartId, input.lineId, input.merchandiseId, quantity, context),
167
+ }
168
+ } catch (error) {
169
+ return { ok: false, error: toError(error, 'No se pudo actualizar la cantidad.') }
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Apply a quantity DELTA from the Navbar mini-cart. The delta is normalized
175
+ * against the authoritative current line quantity (the cart page emits an
176
+ * absolute quantity and uses `setLineQuantityAction`).
177
+ */
178
+ export async function applyQuantityDeltaAction(input: {
179
+ lineId: string
180
+ delta: number
181
+ }): Promise<CartActionResult> {
182
+ try {
183
+ const { cartId, context } = await ensureActiveCartId()
184
+ return { ok: true, cart: await applyLineDelta(cartId, input.lineId, input.delta, context) }
185
+ } catch (error) {
186
+ return { ok: false, error: toError(error, 'No se pudo actualizar la cantidad.') }
187
+ }
188
+ }
189
+
190
+ /** Remove a backend cart line by its line-item id. */
191
+ export async function removeLineAction(input: { lineId: string }): Promise<CartActionResult> {
192
+ try {
193
+ const { cartId, context } = await ensureActiveCartId()
194
+ return { ok: true, cart: await removeLine(cartId, input.lineId, context) }
195
+ } catch (error) {
196
+ return { ok: false, error: toError(error, 'No se pudo quitar el producto.') }
197
+ }
198
+ }
199
+
200
+ /** Read the current cart snapshot (no creation). */
201
+ export async function loadCartStateAction(): Promise<CartStatePatch> {
202
+ try {
203
+ const context = getStoreContext()
204
+ const session = await readHostSession(context)
205
+ if (!session?.cartId) return EMPTY_CART_STATE
206
+ return (await readCart(session.cartId, context)) ?? EMPTY_CART_STATE
207
+ } catch {
208
+ return EMPTY_CART_STATE
209
+ }
210
+ }
211
+
212
+ function toAddressInput(values: Record<string, unknown>): AddressInput {
213
+ const str = (key: string) => (typeof values[key] === 'string' ? (values[key] as string) : '')
214
+ return {
215
+ email: str('email'),
216
+ firstName: str('firstName'),
217
+ lastName: str('lastName'),
218
+ address1: str('address'),
219
+ city: str('city'),
220
+ province: str('state'),
221
+ postalCode: str('zipCode'),
222
+ countryCode: str('country'),
223
+ phone: str('phone'),
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Validate and persist the checkout address against the cart region's eligible
229
+ * countries, then load the eligible shipping options and recalculated totals.
230
+ * A malformed or ineligible address returns structured per-field errors and
231
+ * never advances.
232
+ */
233
+ export async function syncCheckoutAddressAction(
234
+ values: Record<string, unknown>,
235
+ ): Promise<CheckoutActionResult> {
236
+ try {
237
+ const { cartId, context } = await ensureActiveCartId()
238
+ const countries = await loadCheckoutCountries(cartId, context)
239
+ const validation = validateAddress(toAddressInput(values), countries)
240
+ if (!validation.success) {
241
+ return {
242
+ ok: false,
243
+ error: 'Revisa los datos de la dirección.',
244
+ fieldErrors: toCheckoutFieldErrors(validation.errors),
245
+ }
246
+ }
247
+ const cart = await saveShippingAddress(cartId, validation.address, context)
248
+ const methods = await loadShippingMethods(cartId, context)
249
+ return { ok: true, cart, shippingOptions: toCheckoutShippingOptions(methods) }
250
+ } catch (error) {
251
+ return { ok: false, error: toError(error, 'No se pudo validar la dirección.') }
252
+ }
253
+ }
254
+
255
+ /** Apply an eligible shipping method; the backend recalculates the totals. */
256
+ export async function selectShippingMethodAction(input: {
257
+ methodId: string
258
+ }): Promise<CheckoutActionResult> {
259
+ try {
260
+ const { cartId, context } = await ensureActiveCartId()
261
+ const cart = await selectShipping(cartId, input.methodId, context)
262
+ return { ok: true, cart }
263
+ } catch (error) {
264
+ return { ok: false, error: toError(error, 'No se pudo seleccionar el envío.') }
265
+ }
266
+ }
267
+
268
+ /** Load the checkout data (countries, payment methods, cart) for the form. */
269
+ export async function loadCheckoutAction(): Promise<CheckoutActionResult> {
270
+ try {
271
+ const { cartId, context } = await ensureActiveCartId()
272
+ const [regionCountries, paymentMethods, methods, cart] = await Promise.all([
273
+ loadCheckoutCountries(cartId, context),
274
+ loadPaymentMethods(context),
275
+ loadShippingMethods(cartId, context),
276
+ readCart(cartId, context),
277
+ ])
278
+ return {
279
+ ok: true,
280
+ cart: cart ?? EMPTY_CART_STATE,
281
+ countries: regionCountries.map((country) => ({ value: country.code, label: country.name })),
282
+ paymentMethods,
283
+ shippingOptions: toCheckoutShippingOptions(methods),
284
+ }
285
+ } catch (error) {
286
+ return { ok: false, error: toError(error, 'No se pudo cargar el checkout.') }
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Clear the active cart after a confirmed order. A cookie-write failure must
292
+ * NOT be reported as an ordinary failure — the order already exists — so it is
293
+ * logged (non-secret) and reported to the caller, which returns `unresolved`
294
+ * and leaves the committed cart reconcilable.
295
+ */
296
+ async function clearConfirmedSession(
297
+ context: ReturnType<typeof getStoreContext>,
298
+ cartId: string,
299
+ ): Promise<boolean> {
300
+ try {
301
+ await completeHostSessionCart(context, cartId)
302
+ return true
303
+ } catch (error) {
304
+ logServerError('completeHostSessionCart', error)
305
+ return false
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Place the order exactly once. The approved manual TEST payment is applied
311
+ * after shipping totals are final, then the cart is completed with its native
312
+ * discriminator. On a confirmed order the active cart is cleared and the
313
+ * receipt recorded. If the order committed but the session cookie could not be
314
+ * updated, the result is `unresolved` (reconcile the active committed cart) so
315
+ * the client never treats a committed order as a retryable failure.
316
+ */
317
+ export async function placeOrderAction(): Promise<PlaceOrderResult> {
318
+ try {
319
+ const context = getStoreContext()
320
+ const session = await readHostSession(context)
321
+ if (!session?.cartId) {
322
+ return { status: 'error', message: 'No hay un carrito activo.' }
323
+ }
324
+ const cartId = session.cartId
325
+ try {
326
+ await applyPayment(cartId, context)
327
+ } catch (error) {
328
+ logServerError('applyPayment', error)
329
+ return { status: 'error', message: toError(error, 'No se pudo iniciar el pago.') }
330
+ }
331
+ const result = await completeOrder(cartId, context)
332
+ if (result.status !== 'order') return result
333
+ if (!(await clearConfirmedSession(context, cartId))) {
334
+ return {
335
+ status: 'unresolved',
336
+ message: 'El pedido se registró pero no se pudo actualizar la sesión. Reconcílialo.',
337
+ code: 'SESSION_CLEAR_FAILED',
338
+ }
339
+ }
340
+ return result
341
+ } catch (error) {
342
+ // An unexpected failure during completion is ambiguous: never a blind retry.
343
+ logServerError('placeOrderAction', error)
344
+ return {
345
+ status: 'unresolved',
346
+ message: 'No se pudo confirmar el pedido. Reconcílialo antes de reintentar.',
347
+ code: 'COMPLETE_UNRESOLVED',
348
+ }
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Recover the receipt for a cart the verified cookie authorizes. A client
354
+ * order/cart id is never accepted. An ACTIVE cart the backend already committed
355
+ * is an unresolved completion and is reconciled first; otherwise the completed
356
+ * receipt cart carried by the cookie is reconciled. Read/network failures stay
357
+ * `unresolved` (reconcile-only) and are never a blind retry.
358
+ */
359
+ export async function reconcileReceiptAction(): Promise<PlaceOrderResult> {
360
+ try {
361
+ const context = getStoreContext()
362
+ const session = await readHostSession(context)
363
+ if (!session) {
364
+ return { status: 'unresolved', message: 'No hay un comprobante autorizado para esta sesión.' }
365
+ }
366
+
367
+ // An active cart is reconciled first: the runtime does a single GET and
368
+ // only POSTs complete when the backend committed. A cart the backend did
369
+ // NOT commit returns CART_NOT_COMPLETED, surfaced as a controlled
370
+ // (retryable) error instead of a generic unresolved that never resolves.
371
+ // The active cart is only cleared when it is the committed cart; a new cart
372
+ // started after an earlier order is preserved by the session core.
373
+ if (session.cartId) {
374
+ const result = await reconcileOrder(session.cartId, context)
375
+ if (result.status === 'order') {
376
+ await clearConfirmedSession(context, session.cartId)
377
+ return result
378
+ }
379
+ if (result.status === 'unresolved' && result.code === 'CART_NOT_COMPLETED') {
380
+ return { status: 'error', message: result.message, code: result.code }
381
+ }
382
+ return result
383
+ }
384
+
385
+ const candidate = session.receiptCartId
386
+ if (!candidate) {
387
+ return { status: 'unresolved', message: 'No hay un comprobante autorizado para esta sesión.' }
388
+ }
389
+ const result = await reconcileOrder(candidate, context)
390
+ if (result.status === 'order') {
391
+ await clearConfirmedSession(context, candidate)
392
+ return result
393
+ }
394
+ // A reconcile that proves the backend never committed is definitive, not
395
+ // ambiguous: surface it as a controlled failure so the shopper may retry
396
+ // explicitly (never a blind retry of an ambiguous mutation).
397
+ if (result.status === 'unresolved' && result.code === 'CART_NOT_COMPLETED') {
398
+ return { status: 'error', message: result.message, code: result.code }
399
+ }
400
+ return result
401
+ } catch (error) {
402
+ logServerError('reconcileReceiptAction', error)
403
+ return {
404
+ status: 'unresolved',
405
+ message: 'No se pudo verificar el pedido. Reintenta la reconciliación.',
406
+ code: 'RECONCILE_UNRESOLVED',
407
+ }
408
+ }
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Customer auth actions (change: storefront-customer-auth, task 2.2)
413
+ // ---------------------------------------------------------------------------
414
+ //
415
+ // Auth actions touch ONLY the auth cookie: they never read or write the guest
416
+ // session cookie or the cart (guest continuity by construction, design D5).
417
+ // Input is validated before any backend call, and no token or password is ever
418
+ // logged or returned.
419
+
420
+ /**
421
+ * Authenticate a shopper. Validates input first, then authenticates; a token
422
+ * from the backend is persisted in the httpOnly auth cookie. `requiresAction`
423
+ * is surfaced truthfully rather than faking a logged-in state.
424
+ */
425
+ export async function loginAction(input: {
426
+ email: string
427
+ password: string
428
+ }): Promise<AuthActionResult> {
429
+ const email = typeof input?.email === 'string' ? input.email.trim() : ''
430
+ const password = typeof input?.password === 'string' ? input.password : ''
431
+ if (!EMAIL_PATTERN.test(email)) return { ok: false, error: 'Ingresa un correo válido.' }
432
+ if (password.trim() === '') return { ok: false, error: 'Ingresa tu contraseña.' }
433
+ try {
434
+ const context = getStoreContext()
435
+ const outcome = await authenticate({ email, password })
436
+ if (outcome.requiresAction) {
437
+ return { ok: false, error: 'Se requiere un paso adicional para iniciar sesión.' }
438
+ }
439
+ if (outcome.token) await writeAuthToken(context, outcome.token)
440
+ return { ok: true, customer: outcome.customer }
441
+ } catch (error) {
442
+ logServerError('loginAction', error)
443
+ return { ok: false, error: toError(error, 'No se pudo iniciar sesión.') }
444
+ }
445
+ }
446
+
447
+ /**
448
+ * Register a shopper. Validates input first, then registers; a token from the
449
+ * backend is persisted in the httpOnly auth cookie.
450
+ */
451
+ export async function registerAction(input: {
452
+ email: string
453
+ password: string
454
+ first_name: string
455
+ last_name: string
456
+ phone?: string
457
+ }): Promise<AuthActionResult> {
458
+ const email = typeof input?.email === 'string' ? input.email.trim() : ''
459
+ const password = typeof input?.password === 'string' ? input.password : ''
460
+ const firstName = typeof input?.first_name === 'string' ? input.first_name.trim() : ''
461
+ const lastName = typeof input?.last_name === 'string' ? input.last_name.trim() : ''
462
+ const phone =
463
+ typeof input?.phone === 'string' && input.phone.trim() !== '' ? input.phone.trim() : undefined
464
+ if (!EMAIL_PATTERN.test(email)) return { ok: false, error: 'Ingresa un correo válido.' }
465
+ if (password.trim() === '') return { ok: false, error: 'Ingresa una contraseña.' }
466
+ if (firstName === '') return { ok: false, error: 'Ingresa tu nombre.' }
467
+ if (lastName === '') return { ok: false, error: 'Ingresa tu apellido.' }
468
+ try {
469
+ const context = getStoreContext()
470
+ const outcome = await registerShopper({
471
+ email,
472
+ password,
473
+ first_name: firstName,
474
+ last_name: lastName,
475
+ phone,
476
+ })
477
+ if (outcome.requiresAction) {
478
+ return { ok: false, error: 'Se requiere un paso adicional para iniciar sesión.' }
479
+ }
480
+ if (outcome.token) await writeAuthToken(context, outcome.token)
481
+ return { ok: true, customer: outcome.customer }
482
+ } catch (error) {
483
+ logServerError('registerAction', error)
484
+ return { ok: false, error: toError(error, 'No se pudo crear la cuenta.') }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Log out. The backend logout is best-effort: the authoritative clear is the
490
+ * auth cookie, so a backend failure is tolerated and still reported as success.
491
+ */
492
+ export async function logoutAction(): Promise<LogoutActionResult> {
493
+ try {
494
+ const context = getStoreContext()
495
+ try {
496
+ await endSession()
497
+ } catch (error) {
498
+ logServerError('endSession', error)
499
+ }
500
+ await clearAuthToken(context)
501
+ return { ok: true }
502
+ } catch (error) {
503
+ logServerError('logoutAction', error)
504
+ return { ok: false, error: toError(error, 'No se pudo cerrar la sesión.') }
505
+ }
506
+ }
507
+
508
+ /** Resolve the current customer from the verified auth cookie, or null. */
509
+ export async function loadAccountAction(): Promise<AuthActionResult> {
510
+ try {
511
+ const context = getStoreContext()
512
+ const token = await readAuthToken(context)
513
+ const customer = await loadCustomer(token)
514
+ return { ok: true, customer: customer ?? null }
515
+ } catch (error) {
516
+ logServerError('loadAccountAction', error)
517
+ return { ok: false, error: toError(error, 'No se pudo cargar la cuenta.') }
518
+ }
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Customer orders action (change: storefront-customer-orders, task 2.1)
523
+ // ---------------------------------------------------------------------------
524
+ //
525
+ // This action touches ONLY the auth cookie: it never reads or writes the guest
526
+ // session cookie or the cart (guest continuity by construction), and no token
527
+ // is ever logged or returned. An unauthenticated caller resolves to an empty
528
+ // history without contacting the backend.
529
+
530
+ /** Load the authenticated customer's order history from their verified token. */
531
+ export async function loadOrdersAction(): Promise<OrdersActionResult> {
532
+ const context = getStoreContext()
533
+ const token = await readAuthToken(context)
534
+ if (!token) return { ok: true, orders: [] }
535
+ try {
536
+ const orders = await loadOrders(token, context)
537
+ return { ok: true, orders }
538
+ } catch (error) {
539
+ logServerError('loadOrdersAction', error)
540
+ return { ok: false, error: toError(error, 'No se pudieron cargar tus pedidos.') }
541
+ }
542
+ }
@@ -0,0 +1,66 @@
1
+ // Generated by Thor Commerce storefront (template: assets/templates/app/lib/__STOREFRONT__/cart-state.ts)
2
+ //
3
+ // Read-only cart state for Server Component renders. It reads the signed host
4
+ // cookie and loads the backend cart, but NEVER creates one: a guest identity is
5
+ // created only by a Server Action (`ensureCartAction`). A reload therefore
6
+ // reattaches to the same persisted backend cart through the cookie identity.
7
+ //
8
+ // Server-only and Next-bound (it reads cookies through `./session`). It is
9
+ // imported ONLY by the catch-all page (`app/[[...slug]]/page.tsx`), never by a
10
+ // view module, so the client catalog graph stays free of `next/headers`.
11
+
12
+ import type { CartStatePatch } from './commerce'
13
+ import { EMPTY_CART_STATE, readCart } from './commerce'
14
+ import { getStoreContext } from './store-context'
15
+ import { readHostSession } from './session'
16
+
17
+ /** Cart state for the initial render, plus recovery/read-failure flags. */
18
+ export type CartRenderState = CartStatePatch & {
19
+ receiptAvailable: boolean
20
+ cartReadError: string | null
21
+ }
22
+
23
+ function message(error: unknown, fallback: string): string {
24
+ return error instanceof Error && error.message.trim() !== '' ? error.message : fallback
25
+ }
26
+
27
+ /**
28
+ * Read the shopper's active backend cart for rendering, or the empty cart slice
29
+ * when there is no trusted session or no active cart.
30
+ *
31
+ * A real backend read failure is NOT hidden behind a fake empty cart: the
32
+ * shopper identity is preserved and `cartReadError` is surfaced so the UI can
33
+ * show a visible error. `receiptAvailable` is true when the verified session
34
+ * still carries a completed receipt cart (even after a new cart was started).
35
+ */
36
+ export async function readCartStateForRender(): Promise<CartRenderState> {
37
+ let context: ReturnType<typeof getStoreContext>
38
+ try {
39
+ context = getStoreContext()
40
+ } catch (error) {
41
+ return { ...EMPTY_CART_STATE, receiptAvailable: false, cartReadError: message(error, 'Configuración de tienda inválida.') }
42
+ }
43
+
44
+ let session: Awaited<ReturnType<typeof readHostSession>>
45
+ try {
46
+ session = await readHostSession(context)
47
+ } catch (error) {
48
+ return { ...EMPTY_CART_STATE, receiptAvailable: false, cartReadError: message(error, 'No se pudo leer la sesión.') }
49
+ }
50
+
51
+ const receiptAvailable = Boolean(session?.receiptCartId)
52
+ if (!session?.cartId) return { ...EMPTY_CART_STATE, receiptAvailable, cartReadError: null }
53
+
54
+ try {
55
+ const cart = await readCart(session.cartId, context)
56
+ return { ...(cart ?? EMPTY_CART_STATE), receiptAvailable, cartReadError: null }
57
+ } catch (error) {
58
+ // Preserve the identity and surface the failure — never a fake empty cart.
59
+ return {
60
+ ...EMPTY_CART_STATE,
61
+ cartId: session.cartId,
62
+ receiptAvailable,
63
+ cartReadError: message(error, 'No se pudo cargar el carrito.'),
64
+ }
65
+ }
66
+ }