@forgecart/cli 2.202610141948.0 → 2.202610142335.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 (31) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront/README.md +52 -5
  3. package/templates/storefront/src/app/account/page.tsx +51 -0
  4. package/templates/storefront/src/app/layout.tsx +4 -5
  5. package/templates/storefront/src/app/verify/page.tsx +16 -4
  6. package/templates/storefront/src/components/Header.tsx +13 -1
  7. package/templates/storefront/src/components/account/AccountPanel.tsx +289 -0
  8. package/templates/storefront/src/components/account/RegisterForm.tsx +15 -66
  9. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +2 -2
  10. package/templates/storefront/src/components/account/VerificationResend.tsx +96 -0
  11. package/templates/storefront/src/lib/account/account-state.ts +152 -0
  12. package/templates/storefront/src/lib/account-session.ts +83 -16
  13. package/templates/storefront/src/lib/error-messages.ts +102 -8
  14. package/templates/storefront/src/lib/seo/noindex.ts +5 -0
  15. package/templates/storefront/src/lib/seo/sitemap-entries.ts +2 -0
  16. package/templates/storefront/src/lib/shop-session.ts +71 -18
  17. package/templates/storefront-shadcn/README.md +52 -5
  18. package/templates/storefront-shadcn/src/app/account/page.tsx +51 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +4 -5
  20. package/templates/storefront-shadcn/src/app/verify/page.tsx +16 -4
  21. package/templates/storefront-shadcn/src/components/Header.tsx +17 -1
  22. package/templates/storefront-shadcn/src/components/account/AccountPanel.tsx +290 -0
  23. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +19 -61
  24. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +2 -2
  25. package/templates/storefront-shadcn/src/components/account/VerificationResend.tsx +92 -0
  26. package/templates/storefront-shadcn/src/lib/account/account-state.ts +152 -0
  27. package/templates/storefront-shadcn/src/lib/account-session.ts +83 -16
  28. package/templates/storefront-shadcn/src/lib/error-messages.ts +102 -8
  29. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +5 -0
  30. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +2 -0
  31. package/templates/storefront-shadcn/src/lib/shop-session.ts +71 -18
@@ -1,16 +1,19 @@
1
1
  'use client';
2
2
 
3
- import type { RegisterCustomerInput } from '@forgecart/sdk/shop';
3
+ import type { NativeAuthInput, RegisterCustomerInput } from '@forgecart/sdk/shop';
4
4
 
5
+ import type { AccountCallOutcome } from './account/account-link';
6
+ import { isSessionGone, type ActiveCustomerOutcome } from './account/account-state';
5
7
  import type { RegisterOutcome } from './account/register-state';
6
- import { runSessionOp } from './shop-session';
8
+ import { replaceShopSession, runSessionOp } from './shop-session';
7
9
 
8
10
  /**
9
- * Registering, on the shopper's OWN websocket (#1471).
11
+ * The account calls that run on the shopper's OWN websocket: registering
12
+ * (#1471), and signing in and out (#1093).
10
13
  *
11
14
  * Every other unauthenticated account call in this template is a Server Action
12
- * running on the anonymous channel singleton (`lib/account-actions.ts`). This
13
- * one is not, and the placement is the design rather than a preference:
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:
14
17
  *
15
18
  * 1. CORRECTNESS. `lib/forgecart.ts` memoizes ONE client per language for the
16
19
  * whole Next process, so every visitor's Server Action shares it — and
@@ -18,19 +21,26 @@ import { runSessionOp } from './shop-session';
18
21
  * SESSION-scoped (a session holds one registration claim, which is what
19
22
  * turns a double-clicked submit into one account and one prompt instead of
20
23
  * a refusal), so running it on a shared session would answer one shopper
21
- * with another shopper's pending state. The guarantee is only sound on a
22
- * session that belongs to one person.
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.
23
27
  * 2. INTENT. Registering starts the shopper relationship, and the anonymous
24
- * session this socket holds IS the cart identity that logging in later
25
- * promotes — so the cart someone filled before registering follows them
26
- * into their account.
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.
27
31
  *
28
32
  * That is the opposite placement from `/verify` and `/reset-password`, and
29
33
  * deliberately so: those act on a one-shot token out of a mail, where opening a
30
34
  * session for a visitor who has not shopped yet would attach a cart identity to
31
- * a password reset. The verification RESEND on this page is in that second
32
- * category too — it needs nothing but an address — so it stays a Server Action.
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.
33
42
  */
43
+
34
44
  export async function registerCustomerAccount(
35
45
  input: RegisterCustomerInput,
36
46
  ): Promise<RegisterOutcome> {
@@ -38,10 +48,67 @@ export async function registerCustomerAccount(
38
48
  async (client) =>
39
49
  (await client.customer.registerCustomerAccount({ input })).registerCustomerAccount,
40
50
  );
41
- // `runSessionOp` never throws — a refusal arrives as a typed code, which is
42
- // the ordinary case here (the address may already be registered) and the form
43
- // renders it. The envelope is flattened into the pure outcome union so the
44
- // state table stays the only thing that reads an ErrorCode.
45
51
  if (!result.ok) return { kind: 'error', code: result.error.code };
46
52
  return { kind: 'ok', verificationPending: result.data.verificationPending };
47
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
+ }
@@ -1,24 +1,118 @@
1
1
  import type { ExtractedError } from '@forgecart/sdk/shop';
2
2
 
3
3
  /**
4
- * Shopper-facing messages for the typed checkout error codes.
4
+ * Shopper-facing copy for the typed error codes a storefront call can meet.
5
5
  *
6
6
  * The shop API's error contract ships CODES, never prose — `message` on an
7
7
  * extracted error is the code string itself, and resolving human copy is the
8
8
  * frontend's job (error-handling contract). A raw enum leaking into the UI is
9
9
  * a defect (the address step rendered SHIPPING_ADDRESS_PHONE_REQUIRED
10
10
  * verbatim, 2026-08-09).
11
+ *
12
+ * Two families live here. The checkout's address codes, and every refusal a
13
+ * customer ACCOUNT call can meet (#1093): signing in and out, registering, a
14
+ * verification or reset link, a password or e-mail change. The account family
15
+ * is swept against the shop's own `ErrorCode` enum by
16
+ * `tool/storefront-template-spec` — every member of its `Auth - Self-Service`
17
+ * and `Auth - Service` blocks, plus the sign-in refusal, the anonymous
18
+ * socket's refusal and the taken address — so a refusal added to the platform
19
+ * reddens that sweep until it has copy here. Before #1093 this map held the
20
+ * three address codes alone, and every refused sign-in rendered as the
21
+ * generic line: indistinguishable from the platform faults it was masking.
22
+ *
23
+ * `SHOP_API_UNREACHABLE` is not a platform code but this template's own
24
+ * (`lib/action-result.ts`): the answer when the API could not be asked at all,
25
+ * which is the one failure the generic line's "check your details" is wrong
26
+ * about.
27
+ *
28
+ * A Map rather than an object literal: a lookup keyed by an API-supplied
29
+ * string must not be able to land on `Object.prototype` (`constructor`,
30
+ * `toString`) and render a function as copy.
11
31
  */
12
- const SHOPPER_MESSAGES: Record<string, string> = {
13
- SHIPPING_ADDRESS_PHONE_REQUIRED: 'A phone number is required for delivery updates.',
14
- SHIPPING_ADDRESS_COUNTRY_REQUIRED: 'Please select a country.',
15
- SHIPPING_ADDRESS_COUNTRY_INVALID: 'We can’t ship to the selected country yet.',
16
- };
32
+ const SHOPPER_MESSAGES: ReadonlyMap<string, string> = new Map([
33
+ ['SHIPPING_ADDRESS_PHONE_REQUIRED', 'A phone number is required for delivery updates.'],
34
+ ['SHIPPING_ADDRESS_COUNTRY_REQUIRED', 'Please select a country.'],
35
+ ['SHIPPING_ADDRESS_COUNTRY_INVALID', 'We can’t ship to the selected country yet.'],
36
+ [
37
+ 'INVALID_CREDENTIALS',
38
+ 'That e-mail address and password do not match. Check both and try again.',
39
+ ],
40
+ ['UNAUTHORIZED', 'You are not signed in, or your session has ended. Please sign in again.'],
41
+ [
42
+ 'EMAIL_ADDRESS_CONFLICT',
43
+ 'That e-mail address already has an account. Sign in with it, or reset its password.',
44
+ ],
45
+ [
46
+ 'IDENTIFIER_ALREADY_EXISTS',
47
+ 'That e-mail address already has an account. Sign in with it, or reset its password.',
48
+ ],
49
+ [
50
+ 'NOT_VERIFIED',
51
+ 'This account is not confirmed yet. Open the link in the confirmation e-mail we sent you, then sign in.',
52
+ ],
53
+ [
54
+ 'VERIFICATION_TOKEN_INVALID',
55
+ 'This confirmation link is not valid — it may have been used already. Ask for a new confirmation e-mail.',
56
+ ],
57
+ [
58
+ 'VERIFICATION_TOKEN_EXPIRED',
59
+ 'This confirmation link has expired. Ask for a new confirmation e-mail and open the newest link.',
60
+ ],
61
+ [
62
+ 'PASSWORD_RESET_TOKEN_INVALID',
63
+ 'This password reset link is not valid — it may have been used already. Ask for a new one.',
64
+ ],
65
+ [
66
+ 'PASSWORD_RESET_TOKEN_EXPIRED',
67
+ 'This password reset link has expired. Ask for a new one and open the newest link.',
68
+ ],
69
+ [
70
+ 'PASSWORD_VALIDATION_FAILED',
71
+ 'That password does not meet the store’s requirements. Please choose a different one.',
72
+ ],
73
+ [
74
+ 'PASSWORD_CHANGE_FAILED',
75
+ 'Your password could not be changed. Check your current password and try again.',
76
+ ],
77
+ [
78
+ 'IDENTIFIER_CHANGE_TOKEN_INVALID',
79
+ 'This e-mail change link is not valid — it may have been used already. Ask for the change again.',
80
+ ],
81
+ [
82
+ 'IDENTIFIER_CHANGE_TOKEN_EXPIRED',
83
+ 'This e-mail change link has expired. Ask for the change again and open the newest link.',
84
+ ],
85
+ [
86
+ 'CUSTOMER_NOT_FOUND',
87
+ 'That account does not shop at this store. Sign in with the address you registered here.',
88
+ ],
89
+ ['SESSION_NOT_FOUND', 'Your session has ended. Please sign in again.'],
90
+ [
91
+ 'SESSION_CACHE_READ_FAILED',
92
+ 'We could not check your session just now. Please try again in a moment.',
93
+ ],
94
+ [
95
+ 'SESSION_CACHE_WRITE_FAILED',
96
+ 'We could not save your session just now. Please try again in a moment.',
97
+ ],
98
+ [
99
+ 'SHOP_API_UNREACHABLE',
100
+ 'We could not reach the store just now. Please check your connection and try again.',
101
+ ],
102
+ ]);
17
103
 
18
104
  const FALLBACK_MESSAGE = 'Something went wrong. Please check your details and try again.';
19
105
 
20
- /** Human copy for a typed error — never the raw enum, never undefined. */
106
+ /**
107
+ * Human copy for a typed error — never undefined.
108
+ *
109
+ * A code this map has no copy for renders the generic line WITH the code
110
+ * appended in parentheses: an unmodelled refusal must stay distinguishable
111
+ * from a platform fault, by the shopper who reports it and by the merchant —
112
+ * or their agent — who reads the report. Only an error carrying no code at all
113
+ * renders the generic line bare.
114
+ */
21
115
  export function shopperMessage(error: Pick<ExtractedError, 'code'> | null | undefined): string {
22
116
  if (!error?.code) return FALLBACK_MESSAGE;
23
- return SHOPPER_MESSAGES[error.code] ?? FALLBACK_MESSAGE;
117
+ return SHOPPER_MESSAGES.get(error.code) ?? `${FALLBACK_MESSAGE} (${error.code})`;
24
118
  }
@@ -31,6 +31,10 @@ import { getPublicOrigin } from './public-origin';
31
31
  * no content of its own, identical for every visitor, so there is nothing for a
32
32
  * search result to be about. One entry covers every language: this set is
33
33
  * matched against the locale-STRIPPED path.
34
+ *
35
+ * `/account` (#1093) is here for the same funnel reason as `/register`: the
36
+ * server renders a skeleton, identical for every visitor, and everything the
37
+ * page shows is one shopper's own — sign-in form or greeting.
34
38
  */
35
39
  export const NOINDEX_PATHS: readonly string[] = [
36
40
  '/cart',
@@ -38,6 +42,7 @@ export const NOINDEX_PATHS: readonly string[] = [
38
42
  '/verify',
39
43
  '/reset-password',
40
44
  '/register',
45
+ '/account',
41
46
  ];
42
47
 
43
48
  /**
@@ -49,6 +49,8 @@ export const STATIC_ROUTES: readonly StaticRoute[] = [
49
49
  // The register page (#1471). Suppressed for the same reason `/cart` is — a
50
50
  // form with no content of its own — and listed for this table's stated one.
51
51
  { path: '/register', indexable: false },
52
+ // The account page (#1093): sign-in form or greeting, one shopper's own.
53
+ { path: '/account', indexable: false },
52
54
  ];
53
55
 
54
56
  /**
@@ -29,6 +29,10 @@ import { syncShopSession } from './session-actions';
29
29
  * it from result extensions on the first mutation) is synced back into
30
30
  * BOTH cookies via the `syncShopSession` server action — the next SSR
31
31
  * pass sees the cart this socket built.
32
+ * - A sign-in or sign-out changes WHO the session is, and that never rides
33
+ * the capture: `replaceShopSession(token)` disposes the socket, opens a
34
+ * fresh one on the new session (or none) and writes both cookies before
35
+ * it resolves (`lib/account-session.ts` is its caller).
32
36
  *
33
37
  * Every operation returns the same `ActionResult` envelope the old Server
34
38
  * Actions used, so calling components keep their error contract: typed
@@ -96,6 +100,24 @@ export function isSessionReady(): boolean {
96
100
  return config !== null && typeof window !== 'undefined';
97
101
  }
98
102
 
103
+ /** A client on the channel coordinates, carrying `token` — or no session when null. */
104
+ function openClient(coordinates: ShopSessionConfig, token: string | null): ForgeCartShopClient {
105
+ const opened = new ForgeCartShopClient({
106
+ endpoint: coordinates.apiUrl,
107
+ channelToken: coordinates.channelToken,
108
+ // Fixed at handshake — the cart, checkout and every error message on
109
+ // this socket answer in the language the URL resolved to.
110
+ languageCode: coordinates.languageCode,
111
+ // The visitor mirror is minted by the middleware before any page can
112
+ // mount the provider, so it is present on every real navigation; when
113
+ // it is somehow absent the client simply sends no visitor header and
114
+ // visitor-subject goals stay unstamped (never guess — #1079).
115
+ visitorId: readMirrorCookie(VISITOR_MIRROR_COOKIE) ?? undefined,
116
+ });
117
+ if (token) opened.setAuthToken(token);
118
+ return opened;
119
+ }
120
+
99
121
  function ensureClient(): ForgeCartShopClient {
100
122
  if (!config) {
101
123
  throw new Error(
@@ -103,32 +125,63 @@ function ensureClient(): ForgeCartShopClient {
103
125
  );
104
126
  }
105
127
  if (!client) {
106
- client = new ForgeCartShopClient({
107
- endpoint: config.apiUrl,
108
- channelToken: config.channelToken,
109
- // Fixed at handshake — the cart, checkout and every error message on
110
- // this socket answer in the language the URL resolved to.
111
- languageCode: config.languageCode,
112
- // The visitor mirror is minted by the middleware before any page can
113
- // mount the provider, so it is present on every real navigation; when
114
- // it is somehow absent the client simply sends no visitor header and
115
- // visitor-subject goals stay unstamped (never guess — #1079).
116
- visitorId: readMirrorCookie(VISITOR_MIRROR_COOKIE) ?? undefined,
117
- });
118
128
  const existing = readMirrorSession();
119
- if (existing) {
120
- client.setAuthToken(existing);
121
- syncedToken = existing;
122
- }
129
+ client = openClient(config, existing);
130
+ if (existing) syncedToken = existing;
123
131
  }
124
132
  return client;
125
133
  }
126
134
 
135
+ /**
136
+ * Move this tab onto another session — the ONE path an identity change takes
137
+ * (#1093): a sign-in hands over the session it minted, a sign-out hands over
138
+ * `null`.
139
+ *
140
+ * The socket is REPLACED, never re-pointed. Its identity was fixed at its
141
+ * handshake, and a sign-in that left the live socket speaking for the session
142
+ * it opened with was refused UNAUTHORIZED on its very next operation — the
143
+ * greeting — and rendered as a failed sign-in (#1089). So the old client is
144
+ * disposed, which is terminal (nothing still in flight on it can complete
145
+ * under the wrong identity), and a fresh one carrying `token` takes its place;
146
+ * its first operation handshakes as the new session. The platform now rebinds
147
+ * a connection that signs in, and the SDK re-handshakes on `setAuthToken`; the
148
+ * replacement stays regardless, so which identity every later operation runs
149
+ * under is a fact of this module rather than of the platform and SDK versions
150
+ * a workspace happens to run.
151
+ *
152
+ * A sign-out also clears the token the SDK persists in the browser's storage.
153
+ * A fresh client restores that token when it is constructed, so without the
154
+ * clear the replacement would carry the very session it was meant to leave.
155
+ *
156
+ * Both cookies are written next, and AWAITED — unlike the capture in
157
+ * `runSessionOp` — so a caller that resolves after this has left the next
158
+ * server render and the next visit's socket on the same session as this tab.
159
+ * A failed write is absorbed exactly as the capture absorbs one: the socket
160
+ * is the authority for this tab, and a lost mirror costs the next visit its
161
+ * session, never this interaction.
162
+ *
163
+ * Before the cart provider has armed the singleton there is no socket to
164
+ * replace, and only the cookies move.
165
+ */
166
+ export async function replaceShopSession(token: string | null): Promise<void> {
167
+ client?.dispose();
168
+ client = config ? openClient(config, token) : null;
169
+ if (token === null) client?.clearAuthToken();
170
+ syncedToken = token;
171
+ try {
172
+ await syncShopSession(token);
173
+ } catch {
174
+ // Absorbed by design — see above: the socket already carries `token`.
175
+ }
176
+ }
177
+
127
178
  /**
128
179
  * Run one session operation on the shopper's socket and return the typed
129
180
  * envelope. Session capture: when the operation minted or rotated the
130
181
  * session, the new token is synced into the cookies fire-and-forget —
131
- * a sync failure never breaks the shopper's interaction.
182
+ * a sync failure never breaks the shopper's interaction. An operation whose
183
+ * socket was replaced while it ran captures nothing: its session is no longer
184
+ * this tab's, and writing it would undo the replacement.
132
185
  */
133
186
  export async function runSessionOp<T>(
134
187
  op: (client: ForgeCartShopClient) => Promise<T>,
@@ -140,7 +193,7 @@ export async function runSessionOp<T>(
140
193
  try {
141
194
  const data = await op(live);
142
195
  const token = live.getAuthToken();
143
- if (token && token !== syncedToken) {
196
+ if (token && token !== syncedToken && live === client) {
144
197
  syncedToken = token;
145
198
  void syncShopSession(token).catch(() => undefined);
146
199
  }