@thorprovider/storefront 0.3.1 → 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 (263) hide show
  1. package/README.md +321 -152
  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 +54 -2
  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 +17 -7
  9. package/assets/templates/app/app/robots.ts +19 -0
  10. package/assets/templates/app/app/sitemap.ts +71 -0
  11. package/assets/templates/app/lib/__STOREFRONT__/actions.ts +612 -0
  12. package/assets/templates/app/lib/__STOREFRONT__/cart-state.ts +66 -0
  13. package/assets/templates/app/lib/__STOREFRONT__/catalog.ts +50 -57
  14. package/assets/templates/app/lib/__STOREFRONT__/commerce.ts +679 -0
  15. package/assets/templates/app/lib/__STOREFRONT__/composition.ts +26 -0
  16. package/assets/templates/app/lib/__STOREFRONT__/handlers.core.ts +197 -0
  17. package/assets/templates/app/lib/__STOREFRONT__/handlers.ts +5 -36
  18. package/assets/templates/app/lib/__STOREFRONT__/icon-map.ts +27 -0
  19. package/assets/templates/app/lib/__STOREFRONT__/mappers.ts +13 -11
  20. package/assets/templates/app/lib/__STOREFRONT__/registry.tsx +264 -51
  21. package/assets/templates/app/lib/__STOREFRONT__/runtime.ts +6 -3
  22. package/assets/templates/app/lib/__STOREFRONT__/schema.ts +136 -0
  23. package/assets/templates/app/lib/__STOREFRONT__/section-observation.ts +182 -0
  24. package/assets/templates/app/lib/__STOREFRONT__/section-sources.ts +130 -0
  25. package/assets/templates/app/lib/__STOREFRONT__/session.core.ts +470 -0
  26. package/assets/templates/app/lib/__STOREFRONT__/session.ts +169 -0
  27. package/assets/templates/app/lib/__STOREFRONT__/shell/content.ts +120 -0
  28. package/assets/templates/app/lib/__STOREFRONT__/shell/props.ts +38 -0
  29. package/assets/templates/app/lib/__STOREFRONT__/spec/index.ts +129 -10
  30. package/assets/templates/app/lib/__STOREFRONT__/spec/page-root.ts +5 -1
  31. package/assets/templates/app/lib/__STOREFRONT__/spec/seo.ts +191 -0
  32. package/assets/templates/app/lib/__STOREFRONT__/state.core.ts +257 -0
  33. package/assets/templates/app/lib/__STOREFRONT__/state.ts +7 -31
  34. package/assets/templates/app/lib/__STOREFRONT__/store-context.ts +161 -0
  35. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.client.tsx +25 -0
  36. package/assets/templates/app/lib/__STOREFRONT__/views/sections/cta.tsx +16 -0
  37. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.client.tsx +24 -0
  38. package/assets/templates/app/lib/__STOREFRONT__/views/sections/faq.tsx +5 -18
  39. package/assets/templates/app/lib/__STOREFRONT__/views/sections/index.ts +18 -0
  40. package/assets/templates/app/lib/__STOREFRONT__/views/sections/logoCloud.tsx +26 -14
  41. package/assets/templates/app/lib/__STOREFRONT__/views/sections/pricing.tsx +36 -18
  42. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.client.tsx +100 -0
  43. package/assets/templates/app/lib/__STOREFRONT__/views/sections/products.tsx +19 -0
  44. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.cta.ts +19 -0
  45. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.faq.ts +18 -0
  46. package/assets/templates/app/lib/__STOREFRONT__/views/sections/schema.products.ts +39 -0
  47. package/assets/templates/app/lib/__STOREFRONT__/views/sections/statistics.tsx +35 -14
  48. package/assets/templates/app/lib/__STOREFRONT__/views/sections/testimonials.tsx +31 -13
  49. package/assets/templates/app/lib/__STOREFRONT__/views/types.ts +5 -3
  50. package/assets/templates/features/checkout/handlers.ts +159 -0
  51. package/assets/templates/features/checkout/state.ts +61 -0
  52. package/assets/templates/features/customer-auth/handlers.ts +59 -0
  53. package/assets/templates/features/customer-auth/state.ts +45 -0
  54. package/assets/templates/features/recommendations/state.ts +11 -0
  55. package/assets/templates/views/account/content.ts +35 -0
  56. package/assets/templates/views/account/index.tsx +114 -0
  57. package/assets/templates/views/account/registry.tsx +439 -0
  58. package/assets/templates/views/account/schema.ts +53 -0
  59. package/assets/templates/views/cart/content.ts +11 -0
  60. package/assets/templates/views/cart/data.ts +23 -0
  61. package/assets/templates/views/cart/index.tsx +53 -0
  62. package/assets/templates/views/cart/props.ts +12 -0
  63. package/assets/templates/views/cart/registry.tsx +81 -0
  64. package/assets/templates/views/cart/schema.ts +52 -0
  65. package/assets/templates/views/category/assembly/assembly.json +29 -0
  66. package/assets/templates/views/category/assembly/index.skeleton.tsx +251 -0
  67. package/assets/templates/views/category/assembly/registry.skeleton.tsx +34 -0
  68. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/binding.json +5 -0
  69. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/catalog.part +4 -0
  70. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/element.part +7 -0
  71. package/assets/templates/views/category/assembly/slots/breadcrumb/Breadcrumb/registry.part +18 -0
  72. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/catalog.part +5 -0
  73. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/element.part +34 -0
  74. package/assets/templates/views/category/assembly/slots/filter/CategoryFilter2/registry.part +71 -0
  75. package/assets/templates/views/category/content.ts +30 -0
  76. package/assets/templates/views/category/data.ts +12 -0
  77. package/assets/templates/views/category/index.tsx +299 -0
  78. package/assets/templates/views/category/props.ts +23 -0
  79. package/assets/templates/views/category/registry.tsx +122 -0
  80. package/assets/templates/views/category/schema.ts +91 -0
  81. package/assets/templates/views/checkout/content.ts +105 -0
  82. package/assets/templates/views/checkout/data.ts +23 -0
  83. package/assets/templates/views/checkout/index.tsx +111 -0
  84. package/assets/templates/views/checkout/props.ts +49 -0
  85. package/assets/templates/views/checkout/registry.tsx +217 -0
  86. package/assets/templates/views/checkout/schema.ts +229 -0
  87. package/assets/templates/views/home/assembly/assembly.json +33 -0
  88. package/assets/templates/views/home/assembly/index.skeleton.tsx +90 -0
  89. package/assets/templates/views/home/assembly/registry.skeleton.tsx +25 -0
  90. package/assets/templates/views/home/assembly/slots/benefits/Feature1/binding.json +5 -0
  91. package/assets/templates/views/home/assembly/slots/benefits/Feature1/catalog.part +4 -0
  92. package/assets/templates/views/home/assembly/slots/benefits/Feature1/element.part +4 -0
  93. package/assets/templates/views/home/assembly/slots/benefits/Feature1/registry.part +11 -0
  94. package/assets/templates/views/home/assembly/slots/benefits/Feature2/binding.json +5 -0
  95. package/assets/templates/views/home/assembly/slots/benefits/Feature2/catalog.part +4 -0
  96. package/assets/templates/views/home/assembly/slots/benefits/Feature2/element.part +4 -0
  97. package/assets/templates/views/home/assembly/slots/benefits/Feature2/registry.part +10 -0
  98. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/binding.json +5 -0
  99. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/catalog.part +4 -0
  100. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/element.part +5 -0
  101. package/assets/templates/views/home/assembly/slots/categories/ProductCategory2/registry.part +16 -0
  102. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/binding.json +5 -0
  103. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/catalog.part +4 -0
  104. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/element.part +5 -0
  105. package/assets/templates/views/home/assembly/slots/hero/StorefrontHero1/registry.part +26 -0
  106. package/assets/templates/views/home/content.ts +70 -0
  107. package/assets/templates/views/home/data.ts +24 -0
  108. package/assets/templates/views/home/index.tsx +57 -85
  109. package/assets/templates/views/home/props.ts +22 -0
  110. package/assets/templates/views/home/registry.tsx +38 -22
  111. package/assets/templates/views/home/schema.ts +93 -0
  112. package/assets/templates/views/product/content.ts +25 -0
  113. package/assets/templates/views/product/data.ts +50 -0
  114. package/assets/templates/views/product/index.tsx +148 -0
  115. package/assets/templates/views/product/props.ts +27 -0
  116. package/assets/templates/views/product/registry.tsx +103 -0
  117. package/assets/templates/views/product/schema.ts +121 -0
  118. package/assets/templates/views/static/content.ts +99 -0
  119. package/assets/templates/views/static/index.tsx +60 -0
  120. package/assets/templates/views/static/registry.tsx +66 -0
  121. package/assets/templates/views/static/schema.ts +45 -0
  122. package/bin/storefront.js +2424 -148
  123. package/examples/merchandising/category-led.json +37 -0
  124. package/examples/merchandising/collection-led.json +37 -0
  125. package/package.json +15 -9
  126. package/recipes/archetypes.json +28 -14
  127. package/recipes/catalog.json +400 -0
  128. package/recipes/catalog.mjs +128 -0
  129. package/recipes/catalog.schema.json +64 -0
  130. package/recipes/feature.schema.json +89 -0
  131. package/recipes/features.json +162 -0
  132. package/recipes/manifest.schema.json +69 -0
  133. package/recipes/operations.schema.json +43 -0
  134. package/recipes/recipe.schema.json +11 -2
  135. package/recipes/recipes.json +76 -19
  136. package/recipes/sections.json +34 -5
  137. package/recipes/validate.mjs +838 -32
  138. package/scripts/candidate.mjs +236 -0
  139. package/scripts/composers.mjs +160 -0
  140. package/scripts/env-file.mjs +86 -0
  141. package/scripts/file-classes.mjs +121 -0
  142. package/scripts/logical-controls.mjs +61 -0
  143. package/scripts/manifest.mjs +298 -0
  144. package/scripts/migrate-english-routes.mjs +276 -0
  145. package/scripts/operation-registry.mjs +256 -0
  146. package/scripts/operations.mjs +720 -0
  147. package/scripts/ownership-conflict.mjs +356 -0
  148. package/scripts/pinning.mjs +573 -0
  149. package/scripts/plan-apply.mjs +987 -0
  150. package/scripts/preflight.mjs +51 -0
  151. package/scripts/reconcile.mjs +549 -0
  152. package/scripts/render-templates.mjs +338 -68
  153. package/scripts/schema-validate.mjs +132 -0
  154. package/scripts/section-compat.mjs +300 -0
  155. package/scripts/section-config.mjs +253 -0
  156. package/scripts/section-operations.mjs +508 -0
  157. package/scripts/starter.mjs +17 -0
  158. package/scripts/sync-assets.mjs +22 -15
  159. package/scripts/theme/color.mjs +185 -0
  160. package/scripts/theme/css-theme.mjs +233 -0
  161. package/scripts/theme/fonts.mjs +123 -0
  162. package/scripts/theme/import-export.mjs +125 -0
  163. package/scripts/theme/preset.mjs +93 -0
  164. package/scripts/theme/theme-plan.mjs +292 -0
  165. package/scripts/theme/tokens.mjs +137 -0
  166. package/scripts/view-assembly.mjs +337 -0
  167. package/scripts/view-selection.mjs +211 -0
  168. package/skills/sf-feature/SKILL.md +80 -0
  169. package/skills/sf-setup/SKILL.md +79 -0
  170. package/skills/sf-theme-gen/SKILL.md +100 -30
  171. package/skills/sf-theme-gen/references/shadcn-theming.md +87 -0
  172. package/skills/{shadcn-theming/references/templates → sf-theme-gen/references}/theme-setup.md +15 -19
  173. package/skills/{shadcn-theming → sf-theme-gen}/references/theming-guide.md +6 -11
  174. package/skills/sf-view-gen/SKILL.md +146 -93
  175. package/skills/sf-view-gen/assets/recipes/archetypes.json +28 -14
  176. package/skills/sf-view-gen/assets/recipes/catalog.json +400 -0
  177. package/skills/sf-view-gen/assets/recipes/catalog.schema.json +64 -0
  178. package/skills/sf-view-gen/assets/recipes/features.json +162 -0
  179. package/skills/sf-view-gen/assets/recipes/recipes.json +76 -19
  180. package/skills/sf-view-gen/assets/recipes/sections.json +34 -5
  181. package/skills/sf-view-gen/assets/templates/views/sections/cta.client.tsx +25 -0
  182. package/skills/sf-view-gen/assets/templates/views/sections/cta.tsx +16 -0
  183. package/skills/sf-view-gen/assets/templates/views/sections/faq.client.tsx +13 -9
  184. package/skills/sf-view-gen/assets/templates/views/sections/faq.tsx +2 -7
  185. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.client.tsx +12 -8
  186. package/skills/sf-view-gen/assets/templates/views/sections/logoCloud.tsx +2 -7
  187. package/skills/sf-view-gen/assets/templates/views/sections/pricing.client.tsx +14 -10
  188. package/skills/sf-view-gen/assets/templates/views/sections/pricing.tsx +2 -9
  189. package/skills/sf-view-gen/assets/templates/views/sections/products.client.tsx +100 -0
  190. package/skills/sf-view-gen/assets/templates/views/sections/products.tsx +19 -0
  191. package/skills/sf-view-gen/assets/templates/views/sections/schema.cta.ts +19 -0
  192. package/skills/sf-view-gen/assets/templates/views/sections/schema.faq.ts +18 -0
  193. package/skills/sf-view-gen/assets/templates/views/sections/schema.logoCloud.ts +19 -0
  194. package/skills/sf-view-gen/assets/templates/views/sections/schema.pricing.ts +26 -0
  195. package/skills/sf-view-gen/assets/templates/views/sections/schema.products.ts +39 -0
  196. package/skills/sf-view-gen/assets/templates/views/sections/schema.statistics.ts +25 -0
  197. package/skills/sf-view-gen/assets/templates/views/sections/schema.testimonials.ts +23 -0
  198. package/skills/sf-view-gen/assets/templates/views/sections/statistics.client.tsx +16 -8
  199. package/skills/sf-view-gen/assets/templates/views/sections/statistics.tsx +2 -7
  200. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.client.tsx +13 -9
  201. package/skills/sf-view-gen/assets/templates/views/sections/testimonials.tsx +2 -5
  202. package/skills/{json-render-core/SKILL.md → sf-view-gen/references/json-render-core.md} +4 -27
  203. package/skills/{json-render-next/SKILL.md → sf-view-gen/references/json-render-next.md} +5 -8
  204. package/skills/{json-render-react/SKILL.md → sf-view-gen/references/json-render-react.md} +3 -5
  205. package/skills/{json-render-shadcn/SKILL.md → sf-view-gen/references/json-render-shadcn.md} +3 -5
  206. package/skills/sf-view-gen/references/views.md +58 -33
  207. package/skills/sf-view-gen/scripts/catalog.mjs +128 -0
  208. package/skills/sf-view-gen/scripts/schema-validate.mjs +132 -0
  209. package/skills/sf-view-gen/scripts/validate.mjs +838 -32
  210. package/skills/storefront-workflow/SKILL.md +179 -0
  211. package/skills/{storefront-best-practices/reference/design.md → storefront-workflow/references/design-consistency.md} +13 -29
  212. package/CHANGELOG.md +0 -61
  213. package/commands/sf-add-view.md +0 -26
  214. package/commands/sf-theme.md +0 -20
  215. package/commands/sf-view.md +0 -25
  216. package/skills/building-storefronts/SKILL.md +0 -178
  217. package/skills/building-storefronts/references/frontend-integration.md +0 -229
  218. package/skills/json-render-remotion/SKILL.md +0 -111
  219. package/skills/json-render-solid/SKILL.md +0 -204
  220. package/skills/nextjs-shadcn/SKILL.md +0 -367
  221. package/skills/nextjs-shadcn/references/architecture.md +0 -499
  222. package/skills/nextjs-shadcn/references/project-setup.md +0 -162
  223. package/skills/nextjs-shadcn/references/shadcn-platform.md +0 -261
  224. package/skills/nextjs-shadcn/references/sidebar.md +0 -274
  225. package/skills/nextjs-shadcn/references/styling.md +0 -555
  226. package/skills/sf-view-gen/assets/templates/views/category/index.tsx +0 -86
  227. package/skills/sf-view-gen/assets/templates/views/category/registry.tsx +0 -48
  228. package/skills/sf-view-gen/assets/templates/views/checkout/index.tsx +0 -192
  229. package/skills/sf-view-gen/assets/templates/views/checkout/registry.tsx +0 -45
  230. package/skills/sf-view-gen/assets/templates/views/product/index.tsx +0 -122
  231. package/skills/sf-view-gen/assets/templates/views/product/registry.tsx +0 -49
  232. package/skills/sf-view-gen/assets/templates/views/user-panel/index.tsx +0 -168
  233. package/skills/sf-view-gen/assets/templates/views/user-panel/registry.tsx +0 -49
  234. package/skills/shadcn-component-discovery/SKILL.md +0 -273
  235. package/skills/shadcn-component-discovery/references/registries.md +0 -226
  236. package/skills/shadcn-theming/SKILL.md +0 -104
  237. package/skills/storefront-best-practices/SKILL.md +0 -421
  238. package/skills/storefront-best-practices/reference/components/breadcrumbs.md +0 -123
  239. package/skills/storefront-best-practices/reference/components/cart-popup.md +0 -189
  240. package/skills/storefront-best-practices/reference/components/country-selector.md +0 -298
  241. package/skills/storefront-best-practices/reference/components/footer.md +0 -112
  242. package/skills/storefront-best-practices/reference/components/hero.md +0 -241
  243. package/skills/storefront-best-practices/reference/components/megamenu.md +0 -239
  244. package/skills/storefront-best-practices/reference/components/navbar.md +0 -397
  245. package/skills/storefront-best-practices/reference/components/popups.md +0 -221
  246. package/skills/storefront-best-practices/reference/components/product-card.md +0 -125
  247. package/skills/storefront-best-practices/reference/components/product-reviews.md +0 -217
  248. package/skills/storefront-best-practices/reference/components/product-slider.md +0 -174
  249. package/skills/storefront-best-practices/reference/components/search.md +0 -101
  250. package/skills/storefront-best-practices/reference/connecting-to-backend.md +0 -391
  251. package/skills/storefront-best-practices/reference/features/promotions.md +0 -307
  252. package/skills/storefront-best-practices/reference/features/wishlist.md +0 -230
  253. package/skills/storefront-best-practices/reference/layouts/account.md +0 -380
  254. package/skills/storefront-best-practices/reference/layouts/cart.md +0 -316
  255. package/skills/storefront-best-practices/reference/layouts/checkout.md +0 -486
  256. package/skills/storefront-best-practices/reference/layouts/home-page.md +0 -264
  257. package/skills/storefront-best-practices/reference/layouts/order-confirmation.md +0 -231
  258. package/skills/storefront-best-practices/reference/layouts/product-details.md +0 -527
  259. package/skills/storefront-best-practices/reference/layouts/product-listing.md +0 -520
  260. package/skills/storefront-best-practices/reference/layouts/static-pages.md +0 -356
  261. package/skills/storefront-best-practices/reference/medusa.md +0 -307
  262. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/mobile-responsiveness.md +0 -0
  263. /package/skills/{storefront-best-practices/reference → storefront-workflow/references}/seo.md +0 -0
@@ -0,0 +1,612 @@
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
+ loadOrdersPage,
33
+ loadPaymentMethods,
34
+ loadProductDetailById,
35
+ loadShippingMethods,
36
+ readCart,
37
+ reconcileOrder,
38
+ registerShopper,
39
+ removeLine,
40
+ resolveProductCartTarget,
41
+ saveShippingAddress,
42
+ selectShipping,
43
+ setLineQuantity,
44
+ toCheckoutFieldErrors,
45
+ toCheckoutShippingOptions,
46
+ validateAddress,
47
+ type AddressInput,
48
+ type CartStatePatch,
49
+ type PlaceOrderResult,
50
+ } from './commerce'
51
+ import { getStoreContext } from './store-context'
52
+ import {
53
+ bindHostSessionCart,
54
+ clearAuthToken,
55
+ completeHostSessionCart,
56
+ readAuthToken,
57
+ readHostSession,
58
+ writeAuthToken,
59
+ } from './session'
60
+
61
+ // A `'use server'` module may export ONLY async functions. Next/Turbopack's
62
+ // server-actions transform registers every export as a runtime server
63
+ // reference, so a type export (even `export type`) is emitted as a value and
64
+ // crashes at runtime (`X is not defined`). The result types below are local;
65
+ // the shared shapes are imported from `./commerce` and never re-exported here.
66
+
67
+ /** Structured result for a cart-mutating action. */
68
+ type CartActionResult =
69
+ | { ok: true; cart: CartStatePatch }
70
+ | { ok: false; error: string }
71
+
72
+ /** Structured result for a checkout action. */
73
+ type CheckoutActionResult =
74
+ | {
75
+ ok: true
76
+ cart: CartStatePatch
77
+ countries?: { value: string; label: string }[]
78
+ paymentMethods?: PaymentMethod[]
79
+ shippingOptions?: CheckoutShippingOption[]
80
+ fieldErrors?: CheckoutFieldErrors
81
+ }
82
+ | { ok: false; error: string; fieldErrors?: CheckoutFieldErrors }
83
+
84
+ /** Structured result for an auth action that may carry the current customer. */
85
+ type AuthActionResult =
86
+ | { ok: true; customer: Customer | null }
87
+ | { ok: false; error: string }
88
+
89
+ /** Structured result for logout; the authoritative cookie clear is the outcome. */
90
+ type LogoutActionResult = { ok: true } | { ok: false; error: string }
91
+
92
+ /** Structured result for the customer order-history load. */
93
+ type OrdersActionResult =
94
+ | { ok: true; orders: AccountOrderView[] }
95
+ | { ok: false; error: string }
96
+
97
+ /** Structured result for a paginated customer order-history load. */
98
+ type OrdersPageActionResult =
99
+ | { ok: true; orders: AccountOrderView[]; count: number; limit: number; offset: number }
100
+ | { ok: false; error: string }
101
+
102
+ /** Basic email shape check; a boundary guard, not a full RFC validation. */
103
+ const EMAIL_PATTERN = /\S+@\S+\.\S+/
104
+
105
+ /** Log a non-secret context line for a caught server error (never a secret). */
106
+ function logServerError(scope: string, error: unknown): void {
107
+ const message = error instanceof Error ? error.message : String(error)
108
+ const code =
109
+ error && typeof error === 'object' && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
110
+ ? (error as { code: string }).code
111
+ : undefined
112
+ console.error(`[storefront/actions] ${scope} failed`, { message, code })
113
+ }
114
+
115
+ function toError(error: unknown, fallback: string): string {
116
+ return error instanceof Error && error.message.trim() !== '' ? error.message : fallback
117
+ }
118
+
119
+ /**
120
+ * Resolve the active cart id, creating and binding a new backend cart when the
121
+ * session has none or its cart was completed. A new cart after a completed one
122
+ * keeps the SAME shopper and preserves the prior receipt cart id, so the
123
+ * receipt proof cookie is never lost.
124
+ */
125
+ async function ensureActiveCartId(): Promise<{ cartId: string; context: ReturnType<typeof getStoreContext> }> {
126
+ const context = getStoreContext()
127
+ const session = await readHostSession(context)
128
+ if (session?.cartId) {
129
+ const existing = await readCart(session.cartId, context)
130
+ if (existing && !existing.cartCompleted) return { cartId: session.cartId, context }
131
+ }
132
+ const created = await createGuestCart(context)
133
+ if (!created.cartId) throw new Error('Cart creation returned no id')
134
+ await bindHostSessionCart(context, created.cartId)
135
+ return { cartId: created.cartId, context }
136
+ }
137
+
138
+ /** Ensure a guest cart exists and return its authoritative snapshot. */
139
+ export async function ensureCartAction(): Promise<CartActionResult> {
140
+ try {
141
+ const { cartId, context } = await ensureActiveCartId()
142
+ return { ok: true, cart: (await readCart(cartId, context)) ?? EMPTY_CART_STATE }
143
+ } catch (error) {
144
+ return { ok: false, error: toError(error, 'No se pudo iniciar el carrito.') }
145
+ }
146
+ }
147
+
148
+ /** Add the explicitly selected backend variant id at the requested quantity. */
149
+ export async function addToCartAction(input: {
150
+ variantId: string
151
+ quantity: number
152
+ }): Promise<CartActionResult> {
153
+ try {
154
+ if (!input?.variantId) return { ok: false, error: 'Selecciona una variante antes de agregar.' }
155
+ const quantity = Number.isFinite(input.quantity) && input.quantity > 0 ? Math.floor(input.quantity) : 1
156
+ const { cartId, context } = await ensureActiveCartId()
157
+ return { ok: true, cart: await addVariant(cartId, input.variantId, quantity, context) }
158
+ } catch (error) {
159
+ return { ok: false, error: toError(error, 'No se pudo agregar el producto.') }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Add a product's FIRST purchasable variant by backend product id — the
165
+ * product-card shortcut. The variant is resolved server-side from the backend
166
+ * detail (card data carries no variant ids); a product with no purchasable
167
+ * variant returns a truthful error and adds nothing. The product detail remains
168
+ * the explicit-selection surface (change storefront-card-add-to-cart).
169
+ */
170
+ export async function addToCartByProductAction(input: {
171
+ productId: string
172
+ quantity?: number
173
+ }): Promise<CartActionResult> {
174
+ try {
175
+ const productId = typeof input?.productId === 'string' ? input.productId : ''
176
+ if (productId === '') return { ok: false, error: 'Producto no válido.' }
177
+ const quantity =
178
+ Number.isFinite(input?.quantity) && (input.quantity as number) > 0
179
+ ? Math.floor(input.quantity as number)
180
+ : 1
181
+ const detail = await loadProductDetailById(productId)
182
+ const target = resolveProductCartTarget(detail)
183
+ if (!target.ok) return { ok: false, error: target.error }
184
+ const { cartId, context } = await ensureActiveCartId()
185
+ return { ok: true, cart: await addVariant(cartId, target.variantId, quantity, context) }
186
+ } catch (error) {
187
+ return { ok: false, error: toError(error, 'No se pudo agregar el producto.') }
188
+ }
189
+ }
190
+
191
+ /** Set a backend cart line to an ABSOLUTE quantity by its line-item id. */
192
+ export async function setLineQuantityAction(input: {
193
+ lineId: string
194
+ merchandiseId: string
195
+ quantity: number
196
+ }): Promise<CartActionResult> {
197
+ try {
198
+ const quantity = Math.max(1, Math.floor(input.quantity))
199
+ const { cartId, context } = await ensureActiveCartId()
200
+ return {
201
+ ok: true,
202
+ cart: await setLineQuantity(cartId, input.lineId, input.merchandiseId, quantity, context),
203
+ }
204
+ } catch (error) {
205
+ return { ok: false, error: toError(error, 'No se pudo actualizar la cantidad.') }
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Apply a quantity DELTA from the Navbar mini-cart. The delta is normalized
211
+ * against the authoritative current line quantity (the cart page emits an
212
+ * absolute quantity and uses `setLineQuantityAction`).
213
+ */
214
+ export async function applyQuantityDeltaAction(input: {
215
+ lineId: string
216
+ delta: number
217
+ }): Promise<CartActionResult> {
218
+ try {
219
+ const { cartId, context } = await ensureActiveCartId()
220
+ return { ok: true, cart: await applyLineDelta(cartId, input.lineId, input.delta, context) }
221
+ } catch (error) {
222
+ return { ok: false, error: toError(error, 'No se pudo actualizar la cantidad.') }
223
+ }
224
+ }
225
+
226
+ /** Remove a backend cart line by its line-item id. */
227
+ export async function removeLineAction(input: { lineId: string }): Promise<CartActionResult> {
228
+ try {
229
+ const { cartId, context } = await ensureActiveCartId()
230
+ return { ok: true, cart: await removeLine(cartId, input.lineId, context) }
231
+ } catch (error) {
232
+ return { ok: false, error: toError(error, 'No se pudo quitar el producto.') }
233
+ }
234
+ }
235
+
236
+ /** Read the current cart snapshot (no creation). */
237
+ export async function loadCartStateAction(): Promise<CartStatePatch> {
238
+ try {
239
+ const context = getStoreContext()
240
+ const session = await readHostSession(context)
241
+ if (!session?.cartId) return EMPTY_CART_STATE
242
+ return (await readCart(session.cartId, context)) ?? EMPTY_CART_STATE
243
+ } catch {
244
+ return EMPTY_CART_STATE
245
+ }
246
+ }
247
+
248
+ function toAddressInput(values: Record<string, unknown>): AddressInput {
249
+ const str = (key: string) => (typeof values[key] === 'string' ? (values[key] as string) : '')
250
+ return {
251
+ email: str('email'),
252
+ firstName: str('firstName'),
253
+ lastName: str('lastName'),
254
+ address1: str('address'),
255
+ city: str('city'),
256
+ province: str('state'),
257
+ postalCode: str('zipCode'),
258
+ countryCode: str('country'),
259
+ phone: str('phone'),
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Validate and persist the checkout address against the cart region's eligible
265
+ * countries, then load the eligible shipping options and recalculated totals.
266
+ * A malformed or ineligible address returns structured per-field errors and
267
+ * never advances.
268
+ */
269
+ export async function syncCheckoutAddressAction(
270
+ values: Record<string, unknown>,
271
+ ): Promise<CheckoutActionResult> {
272
+ try {
273
+ const { cartId, context } = await ensureActiveCartId()
274
+ const countries = await loadCheckoutCountries(cartId, context)
275
+ const validation = validateAddress(toAddressInput(values), countries)
276
+ if (!validation.success) {
277
+ return {
278
+ ok: false,
279
+ error: 'Revisa los datos de la dirección.',
280
+ fieldErrors: toCheckoutFieldErrors(validation.errors),
281
+ }
282
+ }
283
+ const cart = await saveShippingAddress(cartId, validation.address, context)
284
+ const methods = await loadShippingMethods(cartId, context)
285
+ return { ok: true, cart, shippingOptions: toCheckoutShippingOptions(methods) }
286
+ } catch (error) {
287
+ return { ok: false, error: toError(error, 'No se pudo validar la dirección.') }
288
+ }
289
+ }
290
+
291
+ /** Apply an eligible shipping method; the backend recalculates the totals. */
292
+ export async function selectShippingMethodAction(input: {
293
+ methodId: string
294
+ }): Promise<CheckoutActionResult> {
295
+ try {
296
+ const { cartId, context } = await ensureActiveCartId()
297
+ const cart = await selectShipping(cartId, input.methodId, context)
298
+ return { ok: true, cart }
299
+ } catch (error) {
300
+ return { ok: false, error: toError(error, 'No se pudo seleccionar el envío.') }
301
+ }
302
+ }
303
+
304
+ /** Load the checkout data (countries, payment methods, cart) for the form. */
305
+ export async function loadCheckoutAction(): Promise<CheckoutActionResult> {
306
+ try {
307
+ const { cartId, context } = await ensureActiveCartId()
308
+ const [regionCountries, paymentMethods, methods, cart] = await Promise.all([
309
+ loadCheckoutCountries(cartId, context),
310
+ loadPaymentMethods(context),
311
+ loadShippingMethods(cartId, context),
312
+ readCart(cartId, context),
313
+ ])
314
+ return {
315
+ ok: true,
316
+ cart: cart ?? EMPTY_CART_STATE,
317
+ countries: regionCountries.map((country) => ({ value: country.code, label: country.name })),
318
+ paymentMethods,
319
+ shippingOptions: toCheckoutShippingOptions(methods),
320
+ }
321
+ } catch (error) {
322
+ return { ok: false, error: toError(error, 'No se pudo cargar el checkout.') }
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Clear the active cart after a confirmed order. A cookie-write failure must
328
+ * NOT be reported as an ordinary failure — the order already exists — so it is
329
+ * logged (non-secret) and reported to the caller, which returns `unresolved`
330
+ * and leaves the committed cart reconcilable.
331
+ */
332
+ async function clearConfirmedSession(
333
+ context: ReturnType<typeof getStoreContext>,
334
+ cartId: string,
335
+ ): Promise<boolean> {
336
+ try {
337
+ await completeHostSessionCart(context, cartId)
338
+ return true
339
+ } catch (error) {
340
+ logServerError('completeHostSessionCart', error)
341
+ return false
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Place the order exactly once. The approved manual TEST payment is applied
347
+ * after shipping totals are final, then the cart is completed with its native
348
+ * discriminator. On a confirmed order the active cart is cleared and the
349
+ * receipt recorded. If the order committed but the session cookie could not be
350
+ * updated, the result is `unresolved` (reconcile the active committed cart) so
351
+ * the client never treats a committed order as a retryable failure.
352
+ */
353
+ export async function placeOrderAction(): Promise<PlaceOrderResult> {
354
+ try {
355
+ const context = getStoreContext()
356
+ const session = await readHostSession(context)
357
+ if (!session?.cartId) {
358
+ return { status: 'error', message: 'No hay un carrito activo.' }
359
+ }
360
+ const cartId = session.cartId
361
+ try {
362
+ await applyPayment(cartId, context)
363
+ } catch (error) {
364
+ logServerError('applyPayment', error)
365
+ return { status: 'error', message: toError(error, 'No se pudo iniciar el pago.') }
366
+ }
367
+ const result = await completeOrder(cartId, context)
368
+ if (result.status !== 'order') return result
369
+ if (!(await clearConfirmedSession(context, cartId))) {
370
+ return {
371
+ status: 'unresolved',
372
+ message: 'El pedido se registró pero no se pudo actualizar la sesión. Reconcílialo.',
373
+ code: 'SESSION_CLEAR_FAILED',
374
+ }
375
+ }
376
+ return result
377
+ } catch (error) {
378
+ // An unexpected failure during completion is ambiguous: never a blind retry.
379
+ logServerError('placeOrderAction', error)
380
+ return {
381
+ status: 'unresolved',
382
+ message: 'No se pudo confirmar el pedido. Reconcílialo antes de reintentar.',
383
+ code: 'COMPLETE_UNRESOLVED',
384
+ }
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Recover the receipt for a cart the verified cookie authorizes. A client
390
+ * order/cart id is never accepted. An ACTIVE cart the backend already committed
391
+ * is an unresolved completion and is reconciled first; otherwise the completed
392
+ * receipt cart carried by the cookie is reconciled. Read/network failures stay
393
+ * `unresolved` (reconcile-only) and are never a blind retry.
394
+ */
395
+ export async function reconcileReceiptAction(): Promise<PlaceOrderResult> {
396
+ try {
397
+ const context = getStoreContext()
398
+ const session = await readHostSession(context)
399
+ if (!session) {
400
+ return { status: 'unresolved', message: 'No hay un comprobante autorizado para esta sesión.' }
401
+ }
402
+
403
+ // An active cart is reconciled first: the runtime does a single GET and
404
+ // only POSTs complete when the backend committed. A cart the backend did
405
+ // NOT commit returns CART_NOT_COMPLETED, surfaced as a controlled
406
+ // (retryable) error instead of a generic unresolved that never resolves.
407
+ // The active cart is only cleared when it is the committed cart; a new cart
408
+ // started after an earlier order is preserved by the session core.
409
+ if (session.cartId) {
410
+ const result = await reconcileOrder(session.cartId, context)
411
+ if (result.status === 'order') {
412
+ await clearConfirmedSession(context, session.cartId)
413
+ return result
414
+ }
415
+ if (result.status === 'unresolved' && result.code === 'CART_NOT_COMPLETED') {
416
+ return { status: 'error', message: result.message, code: result.code }
417
+ }
418
+ return result
419
+ }
420
+
421
+ const candidate = session.receiptCartId
422
+ if (!candidate) {
423
+ return { status: 'unresolved', message: 'No hay un comprobante autorizado para esta sesión.' }
424
+ }
425
+ const result = await reconcileOrder(candidate, context)
426
+ if (result.status === 'order') {
427
+ await clearConfirmedSession(context, candidate)
428
+ return result
429
+ }
430
+ // A reconcile that proves the backend never committed is definitive, not
431
+ // ambiguous: surface it as a controlled failure so the shopper may retry
432
+ // explicitly (never a blind retry of an ambiguous mutation).
433
+ if (result.status === 'unresolved' && result.code === 'CART_NOT_COMPLETED') {
434
+ return { status: 'error', message: result.message, code: result.code }
435
+ }
436
+ return result
437
+ } catch (error) {
438
+ logServerError('reconcileReceiptAction', error)
439
+ return {
440
+ status: 'unresolved',
441
+ message: 'No se pudo verificar el pedido. Reintenta la reconciliación.',
442
+ code: 'RECONCILE_UNRESOLVED',
443
+ }
444
+ }
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Customer auth actions (change: storefront-customer-auth, task 2.2)
449
+ // ---------------------------------------------------------------------------
450
+ //
451
+ // Auth actions touch ONLY the auth cookie: they never read or write the guest
452
+ // session cookie or the cart (guest continuity by construction, design D5).
453
+ // Input is validated before any backend call, and no token or password is ever
454
+ // logged or returned.
455
+
456
+ /**
457
+ * Authenticate a shopper. Validates input first, then authenticates; a token
458
+ * from the backend is persisted in the httpOnly auth cookie. `requiresAction`
459
+ * is surfaced truthfully rather than faking a logged-in state.
460
+ */
461
+ export async function loginAction(input: {
462
+ email: string
463
+ password: string
464
+ }): Promise<AuthActionResult> {
465
+ const email = typeof input?.email === 'string' ? input.email.trim() : ''
466
+ const password = typeof input?.password === 'string' ? input.password : ''
467
+ if (!EMAIL_PATTERN.test(email)) return { ok: false, error: 'Ingresa un correo válido.' }
468
+ if (password.trim() === '') return { ok: false, error: 'Ingresa tu contraseña.' }
469
+ try {
470
+ const context = getStoreContext()
471
+ const outcome = await authenticate({ email, password })
472
+ if (outcome.requiresAction) {
473
+ return { ok: false, error: 'Se requiere un paso adicional para iniciar sesión.' }
474
+ }
475
+ if (outcome.token) await writeAuthToken(context, outcome.token)
476
+ return { ok: true, customer: outcome.customer }
477
+ } catch (error) {
478
+ logServerError('loginAction', error)
479
+ return { ok: false, error: toError(error, 'No se pudo iniciar sesión.') }
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Register a shopper. Validates input first, then registers; a token from the
485
+ * backend is persisted in the httpOnly auth cookie.
486
+ */
487
+ export async function registerAction(input: {
488
+ email: string
489
+ password: string
490
+ first_name: string
491
+ last_name: string
492
+ phone?: string
493
+ }): Promise<AuthActionResult> {
494
+ const email = typeof input?.email === 'string' ? input.email.trim() : ''
495
+ const password = typeof input?.password === 'string' ? input.password : ''
496
+ const firstName = typeof input?.first_name === 'string' ? input.first_name.trim() : ''
497
+ const lastName = typeof input?.last_name === 'string' ? input.last_name.trim() : ''
498
+ const phone =
499
+ typeof input?.phone === 'string' && input.phone.trim() !== '' ? input.phone.trim() : undefined
500
+ if (!EMAIL_PATTERN.test(email)) return { ok: false, error: 'Ingresa un correo válido.' }
501
+ if (password.trim() === '') return { ok: false, error: 'Ingresa una contraseña.' }
502
+ if (firstName === '') return { ok: false, error: 'Ingresa tu nombre.' }
503
+ if (lastName === '') return { ok: false, error: 'Ingresa tu apellido.' }
504
+ try {
505
+ const context = getStoreContext()
506
+ const outcome = await registerShopper({
507
+ email,
508
+ password,
509
+ first_name: firstName,
510
+ last_name: lastName,
511
+ phone,
512
+ })
513
+ if (outcome.requiresAction) {
514
+ return { ok: false, error: 'Se requiere un paso adicional para iniciar sesión.' }
515
+ }
516
+ if (outcome.token) await writeAuthToken(context, outcome.token)
517
+ return { ok: true, customer: outcome.customer }
518
+ } catch (error) {
519
+ logServerError('registerAction', error)
520
+ return { ok: false, error: toError(error, 'No se pudo crear la cuenta.') }
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Log out. The backend logout is best-effort: the authoritative clear is the
526
+ * auth cookie, so a backend failure is tolerated and still reported as success.
527
+ */
528
+ export async function logoutAction(): Promise<LogoutActionResult> {
529
+ try {
530
+ const context = getStoreContext()
531
+ try {
532
+ await endSession()
533
+ } catch (error) {
534
+ logServerError('endSession', error)
535
+ }
536
+ await clearAuthToken(context)
537
+ return { ok: true }
538
+ } catch (error) {
539
+ logServerError('logoutAction', error)
540
+ return { ok: false, error: toError(error, 'No se pudo cerrar la sesión.') }
541
+ }
542
+ }
543
+
544
+ /** Resolve the current customer from the verified auth cookie, or null. */
545
+ export async function loadAccountAction(): Promise<AuthActionResult> {
546
+ try {
547
+ const context = getStoreContext()
548
+ const token = await readAuthToken(context)
549
+ const customer = await loadCustomer(token)
550
+ return { ok: true, customer: customer ?? null }
551
+ } catch (error) {
552
+ logServerError('loadAccountAction', error)
553
+ return { ok: false, error: toError(error, 'No se pudo cargar la cuenta.') }
554
+ }
555
+ }
556
+
557
+ // ---------------------------------------------------------------------------
558
+ // Customer orders action (change: storefront-customer-orders, task 2.1)
559
+ // ---------------------------------------------------------------------------
560
+ //
561
+ // This action touches ONLY the auth cookie: it never reads or writes the guest
562
+ // session cookie or the cart (guest continuity by construction), and no token
563
+ // is ever logged or returned. An unauthenticated caller resolves to an empty
564
+ // history without contacting the backend.
565
+
566
+ /** Load the authenticated customer's order history from their verified token. */
567
+ export async function loadOrdersAction(): Promise<OrdersActionResult> {
568
+ const context = getStoreContext()
569
+ const token = await readAuthToken(context)
570
+ if (!token) return { ok: true, orders: [] }
571
+ try {
572
+ const orders = await loadOrders(token, context)
573
+ return { ok: true, orders }
574
+ } catch (error) {
575
+ logServerError('loadOrdersAction', error)
576
+ return { ok: false, error: toError(error, 'No se pudieron cargar tus pedidos.') }
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Load ONE bounded page of the authenticated customer's channel-scoped order
582
+ * history through the additive paginated contract, returning the backend's real
583
+ * `count`. A missing token resolves to an empty page with `count: 0` without
584
+ * contacting the backend. This action touches ONLY the auth cookie — never the
585
+ * guest session cookie or the cart — and no token is logged or returned.
586
+ */
587
+ export async function loadOrdersPageAction(input?: {
588
+ page?: number
589
+ pageSize?: number
590
+ }): Promise<OrdersPageActionResult> {
591
+ const context = getStoreContext()
592
+ const token = await readAuthToken(context)
593
+ const page = Number.isInteger(input?.page) && (input?.page as number) > 0 ? (input?.page as number) : 1
594
+ const pageSize =
595
+ Number.isInteger(input?.pageSize) && (input?.pageSize as number) > 0
596
+ ? (input?.pageSize as number)
597
+ : 10
598
+ if (!token) return { ok: true, orders: [], count: 0, limit: pageSize, offset: (page - 1) * pageSize }
599
+ try {
600
+ const result = await loadOrdersPage(token, { page, pageSize }, context)
601
+ return {
602
+ ok: true,
603
+ orders: result.orders,
604
+ count: result.count,
605
+ limit: result.limit,
606
+ offset: result.offset,
607
+ }
608
+ } catch (error) {
609
+ logServerError('loadOrdersPageAction', error)
610
+ return { ok: false, error: toError(error, 'No se pudieron cargar tus pedidos.') }
611
+ }
612
+ }
@@ -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
+ }