@forgecart/cli 2.202610280742.0 → 2.202610280926.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 (218) hide show
  1. package/dist/src/commands/init-questions.d.ts +12 -11
  2. package/dist/src/commands/init-questions.js +15 -23
  3. package/dist/src/commands/init-questions.js.map +1 -1
  4. package/dist/src/commands/init.d.ts +13 -13
  5. package/dist/src/commands/init.js +75 -144
  6. package/dist/src/commands/init.js.map +1 -1
  7. package/dist/src/commands/refresh.d.ts +3 -3
  8. package/dist/src/commands/refresh.js +4 -14
  9. package/dist/src/commands/refresh.js.map +1 -1
  10. package/dist/src/commands/template-manifest.d.ts +2 -5
  11. package/dist/src/commands/template-manifest.js +1 -1
  12. package/dist/src/commands/template-manifest.js.map +1 -1
  13. package/dist/src/commands/type/init.input.d.ts +1 -1
  14. package/dist/src/commands/type/init.types.d.ts +4 -4
  15. package/dist/src/template/template-set.d.ts +17 -27
  16. package/dist/src/template/template-set.js +25 -42
  17. package/dist/src/template/template-set.js.map +1 -1
  18. package/dist/src/template/template-unavailable-error.d.ts +3 -4
  19. package/dist/src/template/template-unavailable-error.js +3 -4
  20. package/dist/src/template/template-unavailable-error.js.map +1 -1
  21. package/dist/src/template/type/template-set.types.d.ts +14 -17
  22. package/dist/src/template/type/template-set.types.js +6 -1
  23. package/dist/src/template/type/template-set.types.js.map +1 -1
  24. package/dist/src/template/util/template-identity.util.d.ts +1 -3
  25. package/dist/src/template/util/template-identity.util.js +0 -5
  26. package/dist/src/template/util/template-identity.util.js.map +1 -1
  27. package/package.json +1 -1
  28. package/templates/storefront-blank/.forgecartignore +2 -0
  29. package/templates/storefront-blank/Procfile +1 -0
  30. package/templates/storefront-blank/README.md +86 -0
  31. package/templates/storefront-blank/SEO-MIGRATION.md +718 -0
  32. package/templates/storefront-blank/_gitignore +21 -0
  33. package/templates/storefront-blank/next.config.js +105 -0
  34. package/templates/storefront-blank/package.json +35 -0
  35. package/templates/storefront-blank/postcss.config.js +5 -0
  36. package/templates/storefront-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
  37. package/templates/storefront-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
  38. package/templates/storefront-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  39. package/templates/storefront-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  40. package/templates/storefront-blank/src/app/error.tsx +21 -0
  41. package/templates/storefront-blank/src/app/global-error.tsx +23 -0
  42. package/templates/storefront-blank/src/app/globals.css +110 -0
  43. package/templates/storefront-blank/src/app/layout.tsx +102 -0
  44. package/templates/storefront-blank/src/app/page.tsx +52 -0
  45. package/templates/storefront-blank/src/app/pages/[slug]/not-found.tsx +23 -0
  46. package/templates/storefront-blank/src/app/pages/[slug]/page.tsx +115 -0
  47. package/templates/storefront-blank/src/app/ping/route.ts +21 -0
  48. package/templates/storefront-blank/src/app/robots.ts +69 -0
  49. package/templates/storefront-blank/src/app/sitemap.ts +107 -0
  50. package/templates/storefront-blank/src/components/ForgeErrorBeacon.tsx +102 -0
  51. package/templates/storefront-blank/src/components/ForgeTracker.tsx +479 -0
  52. package/templates/storefront-blank/src/components/ForgecartDesigner.tsx +43 -0
  53. package/templates/storefront-blank/src/components/LocaleLink.tsx +49 -0
  54. package/templates/storefront-blank/src/instrumentation.ts +109 -0
  55. package/templates/storefront-blank/src/lib/account/account-link.ts +76 -0
  56. package/templates/storefront-blank/src/lib/account/account-state.ts +152 -0
  57. package/templates/storefront-blank/src/lib/account/register-state.ts +133 -0
  58. package/templates/storefront-blank/src/lib/account/reset-password-state.ts +111 -0
  59. package/templates/storefront-blank/src/lib/account/verify-state.ts +56 -0
  60. package/templates/storefront-blank/src/lib/account-actions.ts +76 -0
  61. package/templates/storefront-blank/src/lib/account-session.ts +114 -0
  62. package/templates/storefront-blank/src/lib/action-result.ts +30 -0
  63. package/templates/storefront-blank/src/lib/asset-alt.ts +34 -0
  64. package/templates/storefront-blank/src/lib/backend-actions.ts +20 -0
  65. package/templates/storefront-blank/src/lib/backend-client.ts +47 -0
  66. package/templates/storefront-blank/src/lib/cart-context.tsx +236 -0
  67. package/templates/storefront-blank/src/lib/checkout-session.ts +185 -0
  68. package/templates/storefront-blank/src/lib/content/page-metadata.ts +113 -0
  69. package/templates/storefront-blank/src/lib/content/render-fields.tsx +256 -0
  70. package/templates/storefront-blank/src/lib/content/resolve-page.ts +143 -0
  71. package/templates/storefront-blank/src/lib/error-messages.ts +118 -0
  72. package/templates/storefront-blank/src/lib/experiments.ts +333 -0
  73. package/templates/storefront-blank/src/lib/forgecart.ts +464 -0
  74. package/templates/storefront-blank/src/lib/format.ts +89 -0
  75. package/templates/storefront-blank/src/lib/identify-forward.ts +152 -0
  76. package/templates/storefront-blank/src/lib/locale/channel-locales-loader.ts +169 -0
  77. package/templates/storefront-blank/src/lib/locale/channel-locales-map.ts +46 -0
  78. package/templates/storefront-blank/src/lib/locale/channel-locales.ts +191 -0
  79. package/templates/storefront-blank/src/lib/locale/grammar.ts +194 -0
  80. package/templates/storefront-blank/src/lib/locale/localized-path.ts +55 -0
  81. package/templates/storefront-blank/src/lib/locale/middleware-plan.ts +107 -0
  82. package/templates/storefront-blank/src/lib/locale/request-binding.ts +80 -0
  83. package/templates/storefront-blank/src/lib/locale/request-locale.ts +66 -0
  84. package/templates/storefront-blank/src/lib/marketing-params.ts +213 -0
  85. package/templates/storefront-blank/src/lib/money.ts +50 -0
  86. package/templates/storefront-blank/src/lib/seo/alternates.ts +123 -0
  87. package/templates/storefront-blank/src/lib/seo/json-ld.ts +266 -0
  88. package/templates/storefront-blank/src/lib/seo/metadata.ts +419 -0
  89. package/templates/storefront-blank/src/lib/seo/noindex.ts +223 -0
  90. package/templates/storefront-blank/src/lib/seo/public-origin.ts +166 -0
  91. package/templates/storefront-blank/src/lib/seo/redirect-plan.ts +86 -0
  92. package/templates/storefront-blank/src/lib/seo/resolve-path.ts +107 -0
  93. package/templates/storefront-blank/src/lib/seo/scaffolded-routes.ts +83 -0
  94. package/templates/storefront-blank/src/lib/seo/sidecar.ts +75 -0
  95. package/templates/storefront-blank/src/lib/seo/site-verification.ts +98 -0
  96. package/templates/storefront-blank/src/lib/seo/sitemap-cache.ts +114 -0
  97. package/templates/storefront-blank/src/lib/seo/sitemap-entries.ts +310 -0
  98. package/templates/storefront-blank/src/lib/session-actions.ts +61 -0
  99. package/templates/storefront-blank/src/lib/session-cookies.ts +98 -0
  100. package/templates/storefront-blank/src/lib/shop-config.ts +51 -0
  101. package/templates/storefront-blank/src/lib/shop-session.ts +204 -0
  102. package/templates/storefront-blank/src/lib/track-forward.ts +200 -0
  103. package/templates/storefront-blank/src/lib/uuid.ts +19 -0
  104. package/templates/storefront-blank/src/middleware.ts +379 -0
  105. package/templates/storefront-blank/src/seo/redirects.ts +44 -0
  106. package/templates/storefront-blank/src/seo/static-routes.ts +12 -0
  107. package/templates/storefront-blank/src/server/app.module.ts +18 -0
  108. package/templates/storefront-blank/src/server/backend-api.ts +21 -0
  109. package/templates/storefront-blank/src/server/backend-method.decorator.ts +23 -0
  110. package/templates/storefront-blank/src/server/bootstrap.ts +122 -0
  111. package/templates/storefront-blank/src/server/forge/live-revision.ts +158 -0
  112. package/templates/storefront-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
  113. package/templates/storefront-blank/src/server/forgecart/forgecart.module.ts +9 -0
  114. package/templates/storefront-blank/src/server/runner.ts +90 -0
  115. package/templates/storefront-blank/src/server/types.ts +36 -0
  116. package/templates/storefront-blank/tsconfig.json +25 -0
  117. package/templates/storefront-blank-sdk-floor.json +1174 -0
  118. package/templates/storefront-shadcn-blank/.forgecartignore +2 -0
  119. package/templates/storefront-shadcn-blank/Procfile +1 -0
  120. package/templates/storefront-shadcn-blank/README.md +93 -0
  121. package/templates/storefront-shadcn-blank/SEO-MIGRATION.md +718 -0
  122. package/templates/storefront-shadcn-blank/_gitignore +21 -0
  123. package/templates/storefront-shadcn-blank/components.json +20 -0
  124. package/templates/storefront-shadcn-blank/next.config.js +105 -0
  125. package/templates/storefront-shadcn-blank/package.json +42 -0
  126. package/templates/storefront-shadcn-blank/postcss.config.js +5 -0
  127. package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
  128. package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
  129. package/templates/storefront-shadcn-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  130. package/templates/storefront-shadcn-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  131. package/templates/storefront-shadcn-blank/src/app/error.tsx +23 -0
  132. package/templates/storefront-shadcn-blank/src/app/global-error.tsx +23 -0
  133. package/templates/storefront-shadcn-blank/src/app/globals.css +156 -0
  134. package/templates/storefront-shadcn-blank/src/app/layout.tsx +102 -0
  135. package/templates/storefront-shadcn-blank/src/app/page.tsx +53 -0
  136. package/templates/storefront-shadcn-blank/src/app/pages/[slug]/not-found.tsx +24 -0
  137. package/templates/storefront-shadcn-blank/src/app/pages/[slug]/page.tsx +115 -0
  138. package/templates/storefront-shadcn-blank/src/app/ping/route.ts +21 -0
  139. package/templates/storefront-shadcn-blank/src/app/robots.ts +69 -0
  140. package/templates/storefront-shadcn-blank/src/app/sitemap.ts +107 -0
  141. package/templates/storefront-shadcn-blank/src/components/ForgeErrorBeacon.tsx +102 -0
  142. package/templates/storefront-shadcn-blank/src/components/ForgeTracker.tsx +479 -0
  143. package/templates/storefront-shadcn-blank/src/components/ForgecartDesigner.tsx +43 -0
  144. package/templates/storefront-shadcn-blank/src/components/LocaleLink.tsx +49 -0
  145. package/templates/storefront-shadcn-blank/src/components/ui/alert.tsx +75 -0
  146. package/templates/storefront-shadcn-blank/src/components/ui/badge.tsx +40 -0
  147. package/templates/storefront-shadcn-blank/src/components/ui/button.tsx +64 -0
  148. package/templates/storefront-shadcn-blank/src/components/ui/card.tsx +28 -0
  149. package/templates/storefront-shadcn-blank/src/components/ui/input.tsx +26 -0
  150. package/templates/storefront-shadcn-blank/src/components/ui/label.tsx +22 -0
  151. package/templates/storefront-shadcn-blank/src/components/ui/native-select.tsx +27 -0
  152. package/templates/storefront-shadcn-blank/src/components/ui/skeleton.tsx +21 -0
  153. package/templates/storefront-shadcn-blank/src/components/ui/utils.ts +16 -0
  154. package/templates/storefront-shadcn-blank/src/instrumentation.ts +109 -0
  155. package/templates/storefront-shadcn-blank/src/lib/account/account-link.ts +76 -0
  156. package/templates/storefront-shadcn-blank/src/lib/account/account-state.ts +152 -0
  157. package/templates/storefront-shadcn-blank/src/lib/account/register-state.ts +133 -0
  158. package/templates/storefront-shadcn-blank/src/lib/account/reset-password-state.ts +111 -0
  159. package/templates/storefront-shadcn-blank/src/lib/account/verify-state.ts +56 -0
  160. package/templates/storefront-shadcn-blank/src/lib/account-actions.ts +76 -0
  161. package/templates/storefront-shadcn-blank/src/lib/account-session.ts +114 -0
  162. package/templates/storefront-shadcn-blank/src/lib/action-result.ts +30 -0
  163. package/templates/storefront-shadcn-blank/src/lib/asset-alt.ts +34 -0
  164. package/templates/storefront-shadcn-blank/src/lib/backend-actions.ts +20 -0
  165. package/templates/storefront-shadcn-blank/src/lib/backend-client.ts +47 -0
  166. package/templates/storefront-shadcn-blank/src/lib/cart-context.tsx +236 -0
  167. package/templates/storefront-shadcn-blank/src/lib/checkout-session.ts +185 -0
  168. package/templates/storefront-shadcn-blank/src/lib/content/page-metadata.ts +113 -0
  169. package/templates/storefront-shadcn-blank/src/lib/content/render-fields.tsx +256 -0
  170. package/templates/storefront-shadcn-blank/src/lib/content/resolve-page.ts +143 -0
  171. package/templates/storefront-shadcn-blank/src/lib/error-messages.ts +118 -0
  172. package/templates/storefront-shadcn-blank/src/lib/experiments.ts +333 -0
  173. package/templates/storefront-shadcn-blank/src/lib/forgecart.ts +464 -0
  174. package/templates/storefront-shadcn-blank/src/lib/format.ts +89 -0
  175. package/templates/storefront-shadcn-blank/src/lib/identify-forward.ts +152 -0
  176. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-loader.ts +169 -0
  177. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-map.ts +46 -0
  178. package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales.ts +191 -0
  179. package/templates/storefront-shadcn-blank/src/lib/locale/grammar.ts +194 -0
  180. package/templates/storefront-shadcn-blank/src/lib/locale/localized-path.ts +55 -0
  181. package/templates/storefront-shadcn-blank/src/lib/locale/middleware-plan.ts +107 -0
  182. package/templates/storefront-shadcn-blank/src/lib/locale/request-binding.ts +80 -0
  183. package/templates/storefront-shadcn-blank/src/lib/locale/request-locale.ts +66 -0
  184. package/templates/storefront-shadcn-blank/src/lib/marketing-params.ts +213 -0
  185. package/templates/storefront-shadcn-blank/src/lib/money.ts +50 -0
  186. package/templates/storefront-shadcn-blank/src/lib/seo/alternates.ts +123 -0
  187. package/templates/storefront-shadcn-blank/src/lib/seo/json-ld.ts +266 -0
  188. package/templates/storefront-shadcn-blank/src/lib/seo/metadata.ts +419 -0
  189. package/templates/storefront-shadcn-blank/src/lib/seo/noindex.ts +223 -0
  190. package/templates/storefront-shadcn-blank/src/lib/seo/public-origin.ts +166 -0
  191. package/templates/storefront-shadcn-blank/src/lib/seo/redirect-plan.ts +86 -0
  192. package/templates/storefront-shadcn-blank/src/lib/seo/resolve-path.ts +107 -0
  193. package/templates/storefront-shadcn-blank/src/lib/seo/scaffolded-routes.ts +83 -0
  194. package/templates/storefront-shadcn-blank/src/lib/seo/sidecar.ts +75 -0
  195. package/templates/storefront-shadcn-blank/src/lib/seo/site-verification.ts +98 -0
  196. package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-cache.ts +114 -0
  197. package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-entries.ts +310 -0
  198. package/templates/storefront-shadcn-blank/src/lib/session-actions.ts +61 -0
  199. package/templates/storefront-shadcn-blank/src/lib/session-cookies.ts +98 -0
  200. package/templates/storefront-shadcn-blank/src/lib/shop-config.ts +51 -0
  201. package/templates/storefront-shadcn-blank/src/lib/shop-session.ts +204 -0
  202. package/templates/storefront-shadcn-blank/src/lib/track-forward.ts +200 -0
  203. package/templates/storefront-shadcn-blank/src/lib/uuid.ts +19 -0
  204. package/templates/storefront-shadcn-blank/src/middleware.ts +379 -0
  205. package/templates/storefront-shadcn-blank/src/seo/redirects.ts +44 -0
  206. package/templates/storefront-shadcn-blank/src/seo/static-routes.ts +12 -0
  207. package/templates/storefront-shadcn-blank/src/server/app.module.ts +18 -0
  208. package/templates/storefront-shadcn-blank/src/server/backend-api.ts +21 -0
  209. package/templates/storefront-shadcn-blank/src/server/backend-method.decorator.ts +23 -0
  210. package/templates/storefront-shadcn-blank/src/server/bootstrap.ts +122 -0
  211. package/templates/storefront-shadcn-blank/src/server/forge/live-revision.ts +158 -0
  212. package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
  213. package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart.module.ts +9 -0
  214. package/templates/storefront-shadcn-blank/src/server/runner.ts +90 -0
  215. package/templates/storefront-shadcn-blank/src/server/types.ts +36 -0
  216. package/templates/storefront-shadcn-blank/tsconfig.json +25 -0
  217. package/templates/storefront-shadcn-blank-sdk-floor.json +1174 -0
  218. package/templates/template-set.json +15 -0
@@ -0,0 +1,133 @@
1
+ import type { AccountCallOutcome } from './account-link';
2
+
3
+ /**
4
+ * What `/register` renders, as a value (#1471).
5
+ *
6
+ * PURE BY CONTRACT, for the reasons `account-link.ts` states. The form owns the
7
+ * COPY for each state; this module owns which state a given outcome IS, so the
8
+ * decision is a table of vectors and the form is a render.
9
+ *
10
+ * Unlike `/verify` and `/reset-password`, this page is not reached with a
11
+ * credential — a shopper types into it, and may type into it twice. That is why
12
+ * the states below are not a success/failure pair: the same submit, repeated,
13
+ * is an ORDINARY thing to happen, and what it should render is a prompt rather
14
+ * than an error.
15
+ */
16
+
17
+ /**
18
+ * The states of the register page.
19
+ *
20
+ * - `form` — the initial render, and every render after a
21
+ * refusal the shopper can act on.
22
+ * - `verify-your-email` — the account exists and awaits verification. This is
23
+ * the ONE state a successful registration lands in:
24
+ * registering never signs anyone in (the account is
25
+ * minted unverified and a mail is dispatched), and a
26
+ * storefront that called `login` here was refused
27
+ * NOT_VERIFIED 287 ms later (prod, 2026-09-06). A
28
+ * repeat submit of the same address from the same
29
+ * session lands here too — that is what the shop
30
+ * API's session-scoped idempotency buys: one account,
31
+ * one prompt, no error on a double-clicked button.
32
+ * - `already-registered` — the address belongs to an account this session did
33
+ * not create. It carries the resend affordance, since
34
+ * a shopper who never confirmed their address cannot
35
+ * sign in and has nowhere else to go.
36
+ * - `signed-in-already` — the account is verified; nothing is pending.
37
+ * - `failed` — the shop API was unreachable, or answered a code
38
+ * this template does not model.
39
+ */
40
+ export type RegisterState =
41
+ | 'form'
42
+ | 'verify-your-email'
43
+ | 'already-registered'
44
+ | 'signed-in-already'
45
+ | 'failed';
46
+
47
+ /**
48
+ * The API's answer to a register submit, as a pure value.
49
+ *
50
+ * Wider than {@link AccountCallOutcome} by exactly one field, because this
51
+ * operation's success is not a bare yes: `verificationPending` is the shop API
52
+ * READING `user.verified` back, and it is the point of the #1471 contract —
53
+ * the two successful answers are two different screens.
54
+ */
55
+ export type RegisterOutcome =
56
+ | { kind: 'ok'; verificationPending: boolean }
57
+ | { kind: 'error'; code: string };
58
+
59
+ /**
60
+ * `ErrorCode` → state for `registerCustomerAccount`.
61
+ *
62
+ * Both refusals collapse into ONE state, and that is a correctness requirement
63
+ * rather than a simplification: `IDENTIFIER_ALREADY_EXISTS` is the login
64
+ * identity's refusal and `EMAIL_ADDRESS_CONFLICT` the customer row's, and which
65
+ * one a shopper receives is decided purely by TIMING — the duplicate-address
66
+ * pre-check runs before the identity claim, so a resubmit arriving after the
67
+ * winner's customer row committed gets the second code where an in-flight twin
68
+ * gets the first. Rendering them differently would show two screens for one
69
+ * situation.
70
+ *
71
+ * `NOT_VERIFIED` is deliberately absent. That is the refusal a LOGIN gets on an
72
+ * unverified account, and this page signs nobody in —
73
+ * `registerCustomerAccount` cannot answer with it, so an arm for it would be a
74
+ * branch no vector can reach.
75
+ *
76
+ * Everything absent lands in `failed`: an unrecognised code is a state this
77
+ * template has not been taught, and guessing a friendlier one would put words
78
+ * in the API's mouth.
79
+ */
80
+ const REGISTER_ERROR_STATES: Readonly<Record<string, RegisterState>> = {
81
+ IDENTIFIER_ALREADY_EXISTS: 'already-registered',
82
+ EMAIL_ADDRESS_CONFLICT: 'already-registered',
83
+ };
84
+
85
+ /**
86
+ * The state a submitted registration lands in. Total over the outcome union.
87
+ *
88
+ * The successful answer splits on `verificationPending` rather than assuming
89
+ * it: a fresh registration carrying a password is always pending, but an
90
+ * idempotent repeat can find the shopper verified in the meantime (they opened
91
+ * the link in another tab between the two submits), and sending them to their
92
+ * inbox for a mail they have already used is a dead end.
93
+ */
94
+ export function registerState(outcome: RegisterOutcome): RegisterState {
95
+ if (outcome.kind === 'ok') {
96
+ return outcome.verificationPending ? 'verify-your-email' : 'signed-in-already';
97
+ }
98
+ return REGISTER_ERROR_STATES[outcome.code] ?? 'failed';
99
+ }
100
+
101
+ /**
102
+ * The states a verification resend lands in (`idle` is pre-submit).
103
+ *
104
+ * TWO outcomes, and the missing third is the design. The shop API throttles
105
+ * this endpoint — it is public and it sends mail — but it throttles SILENTLY:
106
+ * inside the window it answers exactly what it answers outside it, and exactly
107
+ * what it answers for an address that has no account at all. A `throttled`
108
+ * state would therefore be an enumeration tell rather than a courtesy, because
109
+ * the only way this storefront could render one is if the API had told it that
110
+ * THIS address has a pending account waiting on a recently-sent mail — the one
111
+ * fact the neutral answer exists to withhold. A throttled resend renders as
112
+ * `sent`, which is also what is true for the shopper: the mail is in their
113
+ * inbox, from a minute ago.
114
+ *
115
+ * `sent` is neutral for the same reason `passwordResetRequestState`'s is, and
116
+ * `failed` means the same thing it does there: we could not ASK, which says
117
+ * nothing about any account and would otherwise be a sent-mail promise we know
118
+ * to be false.
119
+ */
120
+ export type VerificationResendState = 'idle' | 'sent' | 'failed';
121
+
122
+ /**
123
+ * The state a submitted resend lands in.
124
+ *
125
+ * Its own function rather than an alias of `passwordResetRequestState`: the two
126
+ * surfaces have different pre-submit members (`idle` here, `form` there), and
127
+ * aliasing them would make a change to one silently change the other — that
128
+ * these two answers have the same shape today is a fact about the platform's
129
+ * anti-enumeration posture, not a dependency between the pages.
130
+ */
131
+ export function verificationResendState(outcome: AccountCallOutcome): VerificationResendState {
132
+ return outcome.kind === 'ok' ? 'sent' : 'failed';
133
+ }
@@ -0,0 +1,111 @@
1
+ import type { AccountCallOutcome } from './account-link';
2
+
3
+ /**
4
+ * What `/reset-password` renders, as a value (#1472).
5
+ *
6
+ * One route, two modes — the URL's token decides which. WITH a token the page
7
+ * sets a new password; WITHOUT one it asks for an e-mail address and has a new
8
+ * link sent. Both modes start on their form and move to a terminal state when
9
+ * the API answers, so each mode gets its own union rather than one flat set
10
+ * that would let a request-mode render claim a reset-mode state.
11
+ *
12
+ * PURE BY CONTRACT, for the reasons `account-link.ts` states.
13
+ */
14
+
15
+ /** The states a SUBMITTED new password lands in (`form` is pre-submit). */
16
+ export type ResetPasswordSubmitState = 'done' | 'link-invalid' | 'password-rejected' | 'failed';
17
+
18
+ /**
19
+ * The set-a-new-password mode.
20
+ *
21
+ * - `form` — the initial render, and every render after a
22
+ * refusal the shopper can act on.
23
+ * - `done` — the password was changed.
24
+ * - `link-invalid` — the API refused the token: used, expired, or issued
25
+ * by another store. One state, same reasons as the
26
+ * verify page's.
27
+ * - `password-rejected` — the API refused the PASSWORD. The form stays up:
28
+ * the token is still good and the shopper only has to
29
+ * choose again.
30
+ * - `failed` — unreachable, or a code this template does not model.
31
+ */
32
+ export type ResetPasswordState = 'form' | ResetPasswordSubmitState;
33
+
34
+ /** The states a SUBMITTED reset request lands in (`form` is pre-submit). */
35
+ export type PasswordResetRequestSubmitState = 'sent' | 'failed';
36
+
37
+ /**
38
+ * The ask-for-a-link mode.
39
+ *
40
+ * `sent` is deliberately NEUTRAL and covers both of the API's successful
41
+ * answers: it reports whether an account matched, and rendering that difference
42
+ * would turn this form into an account-enumeration oracle — type an address,
43
+ * read the page, learn whether that person shops here. `failed` is reserved for
44
+ * the case where we could not ASK at all, which tells a visitor nothing about
45
+ * any account and would otherwise be a lie dressed as a sent mail.
46
+ */
47
+ export type PasswordResetRequestState = 'form' | PasswordResetRequestSubmitState;
48
+
49
+ /** Why a new-password form cannot be submitted yet. */
50
+ export type NewPasswordProblem = 'empty' | 'mismatch';
51
+
52
+ /**
53
+ * `ErrorCode` → state for `resetPassword`.
54
+ *
55
+ * `CUSTOMER_NOT_FOUND` is NOT the wrong-store case here, unlike on the verify
56
+ * page: consuming a reset token is scoped to the principal class of the
57
+ * requesting store, so another store's token simply does not resolve and
58
+ * arrives as `PASSWORD_RESET_TOKEN_INVALID`. Reaching `CUSTOMER_NOT_FOUND`
59
+ * means the token DID resolve inside this store and the customer row behind it
60
+ * is missing — a platform-side inconsistency, not something a shopper can act
61
+ * on — so it belongs in `failed` with the rest of the unmodelled codes.
62
+ */
63
+ const RESET_ERROR_STATES: Readonly<Record<string, ResetPasswordSubmitState>> = {
64
+ PASSWORD_RESET_TOKEN_INVALID: 'link-invalid',
65
+ PASSWORD_RESET_TOKEN_EXPIRED: 'link-invalid',
66
+ PASSWORD_VALIDATION_FAILED: 'password-rejected',
67
+ };
68
+
69
+ /** The state a submitted new password lands in. */
70
+ export function resetPasswordState(outcome: AccountCallOutcome): ResetPasswordSubmitState {
71
+ if (outcome.kind === 'ok') return 'done';
72
+ return RESET_ERROR_STATES[outcome.code] ?? 'failed';
73
+ }
74
+
75
+ /**
76
+ * The state a submitted reset REQUEST lands in.
77
+ *
78
+ * Every answer the API gives is `sent`; only a failure to reach it is anything
79
+ * else. See {@link PasswordResetRequestState} for why the successful answers
80
+ * are collapsed.
81
+ */
82
+ export function passwordResetRequestState(
83
+ outcome: AccountCallOutcome,
84
+ ): PasswordResetRequestSubmitState {
85
+ return outcome.kind === 'ok' ? 'sent' : 'failed';
86
+ }
87
+
88
+ /**
89
+ * Whether the two password fields may be submitted, and if not, why.
90
+ *
91
+ * A UX affordance, NOT a security boundary, and the distinction is the whole
92
+ * reason the confirmation never leaves the browser: the shop API is the only
93
+ * authority on whether a password is acceptable (it answers
94
+ * `PASSWORD_VALIDATION_FAILED`, which arrives here as `password-rejected`), and
95
+ * a Server Action is a public endpoint that a caller can reach without ever
96
+ * rendering this form. Checking the confirmation client-side saves a shopper a
97
+ * round-trip and a spent token; it decides nothing the server would not decide
98
+ * again.
99
+ *
100
+ * The password is compared UNTRIMMED. Leading and trailing spaces are legal
101
+ * characters in a password, so trimming here would silently accept two values
102
+ * the API will treat as different.
103
+ */
104
+ export function checkNewPassword(
105
+ password: string,
106
+ confirmation: string,
107
+ ): NewPasswordProblem | null {
108
+ if (password.length === 0) return 'empty';
109
+ if (password !== confirmation) return 'mismatch';
110
+ return null;
111
+ }
@@ -0,0 +1,56 @@
1
+ import type { AccountLinkOutcome } from './account-link';
2
+
3
+ /**
4
+ * What `/verify` renders, as a value (#1472).
5
+ *
6
+ * PURE BY CONTRACT, for the reasons `account-link.ts` states. The page owns the
7
+ * COPY for each state; this module owns which state a given outcome is, so the
8
+ * decision is a table of vectors and the page is a render.
9
+ */
10
+
11
+ /**
12
+ * The states of the e-mail verification page.
13
+ *
14
+ * - `no-token` — the URL carried no token this page may act on.
15
+ * - `verified` — the account is now verified.
16
+ * - `link-invalid` — the API refused the token: already used, expired, or
17
+ * never issued. They are ONE state on purpose — the page's
18
+ * advice is identical ("request a new mail") and telling an
19
+ * unauthenticated visitor whether a token they do not hold
20
+ * once existed is an oracle with no upside.
21
+ * - `wrong-store` — the token was valid, but the account it belongs to is not
22
+ * a customer of THIS store.
23
+ * - `failed` — anything else: the shop API was unreachable, or answered
24
+ * a code this template does not model.
25
+ */
26
+ export type VerifyState = 'no-token' | 'verified' | 'link-invalid' | 'wrong-store' | 'failed';
27
+
28
+ /**
29
+ * `ErrorCode` → state, for the codes `verifyCustomerAccount` can answer with.
30
+ *
31
+ * `CUSTOMER_NOT_FOUND` is the `wrong-store` case and the reason that state
32
+ * exists. Verification resolves the token GLOBALLY (the platform's auth method
33
+ * is keyed by the token alone), and only the customer lookup that follows is
34
+ * scoped to the channel this storefront serves — so a token issued by another
35
+ * store passes the first step and fails the second. That is exactly what a
36
+ * shopper holding an OLD mail hits: before #1472 every store's links pointed at
37
+ * one platform-global host, so a stale link can still arrive at the wrong
38
+ * storefront. "Something went wrong" would be true and useless; naming the
39
+ * cause is what lets the shopper act on it.
40
+ *
41
+ * Everything absent from this table lands in `failed` by design: an
42
+ * unrecognised code is a state this template has not been taught, and guessing
43
+ * at a friendlier one would put words in the API's mouth.
44
+ */
45
+ const VERIFY_ERROR_STATES: Readonly<Record<string, VerifyState>> = {
46
+ VERIFICATION_TOKEN_INVALID: 'link-invalid',
47
+ VERIFICATION_TOKEN_EXPIRED: 'link-invalid',
48
+ CUSTOMER_NOT_FOUND: 'wrong-store',
49
+ };
50
+
51
+ /** The state a verification render lands in. Total over the outcome union. */
52
+ export function verifyState(outcome: AccountLinkOutcome): VerifyState {
53
+ if (outcome.kind === 'no-token') return 'no-token';
54
+ if (outcome.kind === 'ok') return 'verified';
55
+ return VERIFY_ERROR_STATES[outcome.code] ?? 'failed';
56
+ }
@@ -0,0 +1,76 @@
1
+ 'use server';
2
+
3
+ import 'server-only';
4
+
5
+ import { verificationResendState, type VerificationResendState } from './account/register-state';
6
+ import {
7
+ passwordResetRequestState,
8
+ resetPasswordState,
9
+ type PasswordResetRequestSubmitState,
10
+ type ResetPasswordSubmitState,
11
+ } from './account/reset-password-state';
12
+ import { requestCustomerVerification, requestPasswordReset, resetPassword } from './forgecart';
13
+
14
+ /**
15
+ * The Server Actions behind the account forms (#1472, #1471).
16
+ *
17
+ * These are the places in this template where a SHOPPER's input reaches the
18
+ * shop API from the server rather than over their own websocket, and that
19
+ * placement is deliberate: all three operations are unauthenticated by design
20
+ * (whoever holds the token, or the address, may call them), so putting them on
21
+ * the session socket would open a session for someone who has not shopped yet
22
+ * and attach a cart identity to a password reset. They run on the anonymous
23
+ * channel singleton in `lib/forgecart.ts` instead.
24
+ *
25
+ * The register SUBMIT is the one account operation that does NOT belong here,
26
+ * for the mirror image of that reason: it is session-scoped, and the session
27
+ * has to be the shopper's own. It lives in `lib/account-session.ts`, which
28
+ * states that argument in full.
29
+ *
30
+ * Each action returns the page STATE, not the API's envelope: the mapping from
31
+ * `ErrorCode` to state lives once, in `lib/account/reset-password-state.ts`,
32
+ * and a string union crosses the RSC boundary as itself. The client form only
33
+ * has to render what it is given.
34
+ *
35
+ * A Server Action is a public HTTP endpoint — a caller can reach these without
36
+ * ever loading the form — which is exactly why neither of them validates
37
+ * anything the API validates: the token and the password are judged by the
38
+ * shop API, and the form's own confirmation check is a UX affordance that
39
+ * never leaves the browser (see `checkNewPassword`). Abuse budgets (how often
40
+ * an address may be mailed) belong to the platform, which owns the mail — and
41
+ * for the verification resend it enforces one silently, which is why no state
42
+ * here names it (see {@link VerificationResendState}).
43
+ */
44
+
45
+ /** Consume a reset token and set the new password. */
46
+ export async function submitPasswordReset(input: {
47
+ token: string;
48
+ password: string;
49
+ }): Promise<ResetPasswordSubmitState> {
50
+ return resetPasswordState(await resetPassword(input));
51
+ }
52
+
53
+ /**
54
+ * Ask for a reset link.
55
+ *
56
+ * Answers the same neutral state whether or not an account matched — see
57
+ * {@link passwordResetRequestState}.
58
+ */
59
+ export async function submitPasswordResetRequest(
60
+ emailAddress: string,
61
+ ): Promise<PasswordResetRequestSubmitState> {
62
+ return passwordResetRequestState(await requestPasswordReset(emailAddress));
63
+ }
64
+
65
+ /**
66
+ * Ask for the verification mail again (#1471).
67
+ *
68
+ * Answers the same neutral state whether or not the address has anything to
69
+ * verify, and whether or not the platform's throttle swallowed this particular
70
+ * request — see {@link VerificationResendState}.
71
+ */
72
+ export async function submitVerificationRequest(
73
+ emailAddress: string,
74
+ ): Promise<VerificationResendState> {
75
+ return verificationResendState(await requestCustomerVerification(emailAddress));
76
+ }
@@ -0,0 +1,114 @@
1
+ 'use client';
2
+
3
+ import type { NativeAuthInput, RegisterCustomerInput } from '@forgecart/sdk/shop';
4
+
5
+ import type { AccountCallOutcome } from './account/account-link';
6
+ import { isSessionGone, type ActiveCustomerOutcome } from './account/account-state';
7
+ import type { RegisterOutcome } from './account/register-state';
8
+ import { replaceShopSession, runSessionOp } from './shop-session';
9
+
10
+ /**
11
+ * The account calls that run on the shopper's OWN websocket: registering
12
+ * (#1471), and signing in and out (#1093).
13
+ *
14
+ * Every other unauthenticated account call in this template is a Server Action
15
+ * running on the anonymous channel singleton (`lib/account-actions.ts`). These
16
+ * are not, and the placement is the design rather than a preference:
17
+ *
18
+ * 1. CORRECTNESS. `lib/forgecart.ts` memoizes ONE client per language for the
19
+ * whole Next process, so every visitor's Server Action shares it — and
20
+ * therefore shares its shop session. The shop API's register idempotency is
21
+ * SESSION-scoped (a session holds one registration claim, which is what
22
+ * turns a double-clicked submit into one account and one prompt instead of
23
+ * a refusal), so running it on a shared session would answer one shopper
24
+ * with another shopper's pending state. A sign-in on that client would be
25
+ * worse still: it would sign EVERY visitor in as whoever signed in last.
26
+ * These calls are only sound on a session that belongs to one person.
27
+ * 2. INTENT. Registering starts the shopper relationship, and the anonymous
28
+ * session this socket holds IS the cart identity that signing in later
29
+ * promotes — so the cart someone filled before registering, or before
30
+ * signing in, follows them into their account.
31
+ *
32
+ * That is the opposite placement from `/verify` and `/reset-password`, and
33
+ * deliberately so: those act on a one-shot token out of a mail, where opening a
34
+ * session for a visitor who has not shopped yet would attach a cart identity to
35
+ * a password reset. The verification RESEND is in that second category too — it
36
+ * needs nothing but an address — so it stays a Server Action.
37
+ *
38
+ * Like `runSessionOp`, none of these throws: a refusal is a typed code in the
39
+ * outcome, which is the ordinary case (a mistyped password, an address already
40
+ * registered), and the pure state tables in `lib/account/` stay the only things
41
+ * that read an ErrorCode.
42
+ */
43
+
44
+ export async function registerCustomerAccount(
45
+ input: RegisterCustomerInput,
46
+ ): Promise<RegisterOutcome> {
47
+ const result = await runSessionOp(
48
+ async (client) =>
49
+ (await client.customer.registerCustomerAccount({ input })).registerCustomerAccount,
50
+ );
51
+ if (!result.ok) return { kind: 'error', code: result.error.code };
52
+ return { kind: 'ok', verificationPending: result.data.verificationPending };
53
+ }
54
+
55
+ /**
56
+ * Sign the shopper in.
57
+ *
58
+ * Resolves only once this tab's socket AND the session cookies carry the
59
+ * session the sign-in minted (`replaceShopSession`), so the caller's very next
60
+ * operation — the greeting — runs as the signed-in shopper. Before #1093 every
61
+ * storefront hand-rolled this, and the one that did not rotate the socket had
62
+ * its next operation refused UNAUTHORIZED and reported a failed sign-in (#1089).
63
+ *
64
+ * A refusal resolves with its code — `INVALID_CREDENTIALS`, `NOT_VERIFIED`, … —
65
+ * and changes nothing: the socket keeps the session it had, cart included.
66
+ */
67
+ export async function loginCustomer(input: NativeAuthInput): Promise<AccountCallOutcome> {
68
+ const result = await runSessionOp(async (client) => (await client.auth.login({ input })).login);
69
+ if (!result.ok) return { kind: 'error', code: result.error.code };
70
+ await replaceShopSession(result.data.token);
71
+ return { kind: 'ok' };
72
+ }
73
+
74
+ /**
75
+ * Sign the shopper out.
76
+ *
77
+ * Asks the shop API to end the session, then leaves this tab without one —
78
+ * the socket replaced by one that carries no session, both cookies cleared —
79
+ * WHATEVER the API answered: signing out is first a promise about this device,
80
+ * and a shopper on a shared computer who pressed the button must not stay
81
+ * signed in because the network blinked. The outcome reports the API's half:
82
+ * `ok` when the session is over (a session the API no longer holds included),
83
+ * the refusal's code when the API could not confirm it.
84
+ */
85
+ export async function logoutCustomer(): Promise<AccountCallOutcome> {
86
+ const ended = await runSessionOp(async (client) => (await client.auth.logout()).logout);
87
+ await replaceShopSession(null);
88
+ if (ended.ok || isSessionGone(ended.error.code)) return { kind: 'ok' };
89
+ return { kind: 'error', code: ended.error.code };
90
+ }
91
+
92
+ /**
93
+ * Who this tab's session belongs to.
94
+ *
95
+ * A socket carrying no session is answered without a request — a visitor who
96
+ * has never shopped, or one who just signed out, belongs to nobody — and so is
97
+ * every page load that finds no mirror cookie, since the socket boots its
98
+ * session from it. Otherwise the shop API is asked, and an anonymous cart
99
+ * session answers `null` too.
100
+ */
101
+ export async function findSignedInCustomer(): Promise<ActiveCustomerOutcome> {
102
+ const result = await runSessionOp(async (client) => {
103
+ if (client.getAuthToken() === null) return null;
104
+ const customer = (await client.customer.activeCustomer()).activeCustomer ?? null;
105
+ if (customer === null) return null;
106
+ return {
107
+ firstName: customer.firstName,
108
+ lastName: customer.lastName,
109
+ emailAddress: customer.emailAddress,
110
+ };
111
+ });
112
+ if (!result.ok) return { kind: 'error', code: result.error.code };
113
+ return { kind: 'ok', customer: result.data };
114
+ }
@@ -0,0 +1,30 @@
1
+ import type { ExtractedError } from '@forgecart/sdk/shop';
2
+
3
+ /**
4
+ * Typed result envelope for every Server Action in this template.
5
+ *
6
+ * Server Actions NEVER throw across the RSC boundary: Next.js production
7
+ * redacts a thrown action error to an opaque digest, so the client would see
8
+ * a generic failure with no code to render. Instead each action catches,
9
+ * translates the failure with the SDK's `extractError` (the shop API's
10
+ * `{ code, variables, classification, message }` extensions — `message`
11
+ * arrives server-localized), and returns it in-band. Callers switch on
12
+ * `ok`; error UIs render `error.message` and key field targeting off
13
+ * `error.code`.
14
+ *
15
+ * The `import type` above erases, so this module is safe for client
16
+ * components — it is the one shape both sides of the action boundary share.
17
+ */
18
+ export type ActionResult<T> = { ok: true; data: T } | { ok: false; error: ExtractedError };
19
+
20
+ /**
21
+ * Fallback envelope error for failures that carry no GraphQL extensions
22
+ * (network refused, DNS, a non-GraphQL throw). `extractError` returns `null`
23
+ * for those; actions substitute this so the client always receives a code it
24
+ * can key on.
25
+ */
26
+ export const UNREACHABLE_ERROR: ExtractedError = {
27
+ code: 'SHOP_API_UNREACHABLE',
28
+ variables: {},
29
+ classification: 'INTERNAL_ERROR',
30
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The `alt` attribute for an asset-backed `<img>` — the one place the template
3
+ * decides what an undescribed image announces (#1376).
4
+ *
5
+ * Alt text is the merchant's OWN description of the image, authored in the
6
+ * dashboard's media library. Its absence is therefore a decision rather than a
7
+ * gap: an image nobody described is announced as decorative (`alt=""`) and
8
+ * skipped by screen readers, which is exactly right for the product shots,
9
+ * badges and thumbnails that repeat text already sitting next to them.
10
+ *
11
+ * It is deliberately NEVER back-filled with the product or variant name. A
12
+ * derived placeholder is indistinguishable from a real description to both a
13
+ * screen reader and a crawler, while carrying nothing the adjacent markup does
14
+ * not already say — so it spends a listener's attention repeating the caption
15
+ * they just heard, and tells a crawler the image was described when it was
16
+ * not. The empty string is the honest answer and the accessible one, and this
17
+ * function exists so that judgement is stated once instead of re-derived at
18
+ * every `<img>` (all four of which read `alt={product.name}` before it).
19
+ *
20
+ * A blank-but-present description collapses to the same decorative default.
21
+ * `alt=" "` is the worst of both worlds — not a description, and not the
22
+ * empty string that marks an image skippable, so a reader falls back to
23
+ * announcing the file name. The dashboard already trims before it writes, but
24
+ * the field is equally writable through the admin API, so the guarantee is
25
+ * made here where it is actually rendered rather than assumed upstream.
26
+ *
27
+ * The parameter is structural, not the SDK's `Asset`: every selection that
28
+ * renders an image picks `altText` alongside `preview`, and asking only for
29
+ * the field actually read keeps this module free of the SDK import — which is
30
+ * what lets it be unit-tested directly (see tool/storefront-template-spec).
31
+ */
32
+ export function getAssetAlt(asset: { altText?: string | null }): string {
33
+ return asset.altText?.trim() ?? '';
34
+ }
@@ -0,0 +1,20 @@
1
+ 'use server';
2
+
3
+ import 'server-only';
4
+
5
+ import { invokeBackendMethod } from '../server/runner';
6
+ import type { ActionResult } from './action-result';
7
+
8
+ /**
9
+ * The ONE Server Action bridging the browser into the embedded NestJS
10
+ * backend (`src/server/`) — Next's native RPC is the transport, the booted
11
+ * registry is the authority. Components never import this directly: they
12
+ * call through `backend` (`lib/backend-client.ts`), which types the surface
13
+ * off `backend-api.ts` and guards the proxy's magic keys.
14
+ */
15
+ export async function invokeBackend(
16
+ method: string,
17
+ input: unknown,
18
+ ): Promise<ActionResult<unknown>> {
19
+ return invokeBackendMethod(method, input);
20
+ }
@@ -0,0 +1,47 @@
1
+ import type { BackendApi } from '../server/backend-api';
2
+ import type { ActionResult } from './action-result';
3
+ import { invokeBackend } from './backend-actions';
4
+
5
+ /**
6
+ * `sdk.backend` — the typed browser gate onto the storefront's embedded
7
+ * NestJS backend.
8
+ *
9
+ * Compile time: the mapped type below projects `backend-api.ts` onto the
10
+ * proxy, so editors autocomplete exactly the registered methods and an
11
+ * unknown name fails the build. (The import is type-only — it erases, so no
12
+ * server code reaches the client bundle; the `server-only` guards enforce
13
+ * it.)
14
+ *
15
+ * Runtime: the proxy fabricates an invoker for ANY string key — the
16
+ * server-side registry is the real authority, answering
17
+ * BACKEND_METHOD_NOT_FOUND in the normal envelope for unknown or
18
+ * stale-bundle names. Magic keys are excluded so the object never
19
+ * masquerades as a thenable (`await backend` must not fire a wire call
20
+ * named "then").
21
+ *
22
+ * Usage, any client component:
23
+ * const result = await backend.assignCustomerHash({});
24
+ * if (!result.ok) return renderError(result.error);
25
+ */
26
+ type BackendGate = {
27
+ [K in keyof BackendApi]: (
28
+ input: Parameters<BackendApi[K]>[0],
29
+ ) => Promise<ActionResult<Awaited<ReturnType<BackendApi[K]>>>>;
30
+ };
31
+
32
+ const RESERVED_KEYS = new Set([
33
+ 'then',
34
+ 'catch',
35
+ 'finally',
36
+ 'toJSON',
37
+ 'valueOf',
38
+ 'constructor',
39
+ '$$typeof',
40
+ ]);
41
+
42
+ export const backend: BackendGate = new Proxy({} as BackendGate, {
43
+ get(_target, key) {
44
+ if (typeof key !== 'string' || RESERVED_KEYS.has(key)) return undefined;
45
+ return (input: unknown) => invokeBackend(key, input);
46
+ },
47
+ });