@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
@@ -3,14 +3,10 @@
3
3
  import { useState } from 'react';
4
4
 
5
5
  import { AccountMessage, type AccountMessageTone } from './AccountMessage';
6
+ import { VerificationResend } from './VerificationResend';
6
7
  import { LocaleLink } from '../LocaleLink';
7
- import { submitVerificationRequest } from '../../lib/account-actions';
8
8
  import { registerCustomerAccount } from '../../lib/account-session';
9
- import {
10
- registerState,
11
- type RegisterState,
12
- type VerificationResendState,
13
- } from '../../lib/account/register-state';
9
+ import { registerState, type RegisterState } from '../../lib/account/register-state';
14
10
  import type { LocaleBinding } from '../../lib/locale/localized-path';
15
11
 
16
12
  /**
@@ -26,8 +22,8 @@ import type { LocaleBinding } from '../../lib/locale/localized-path';
26
22
  * The submit runs on the shopper's OWN websocket rather than through a Server
27
23
  * Action, because the API's protection against a double-clicked submit is
28
24
  * scoped to their session — `lib/account-session.ts` carries the full
29
- * argument. The RESEND below is a Server Action, because it needs nothing but
30
- * an address.
25
+ * argument. The RESEND (`VerificationResend`) is a Server Action, because it
26
+ * needs nothing but an address.
31
27
  *
32
28
  * Which screen an outcome produces is decided in
33
29
  * `lib/account/register-state.ts`; this component renders it.
@@ -56,39 +52,13 @@ const PROMPTS: Readonly<
56
52
  },
57
53
  };
58
54
 
59
- /**
60
- * What a SUBMITTED resend says. `idle` has no entry because it is the
61
- * pre-submit state and renders nothing.
62
- *
63
- * `sent` is worded so it is true for every address a shopper can type — one
64
- * with a pending account, one with none, and one the platform's throttle
65
- * silently swallowed. That is the same neutrality the reset-request form
66
- * carries, and `verificationResendState` is where it is decided.
67
- */
68
- const RESEND_MESSAGES: Readonly<
69
- Record<'sent' | 'failed', { tone: AccountMessageTone; title: string; body: string }>
70
- > = {
71
- sent: {
72
- tone: 'success',
73
- title: 'On its way',
74
- body: 'If that address is waiting to be confirmed, a link is on its way to it. A mail we sent a moment ago still works, so it is worth checking for that one too.',
75
- },
76
- failed: {
77
- tone: 'error',
78
- title: 'We could not send that e-mail',
79
- body: 'Something went wrong on our side and nothing has been sent. Please try again in a few minutes.',
80
- },
81
- };
82
-
83
55
  export function RegisterForm({ locale }: { locale: LocaleBinding }) {
84
56
  const [firstName, setFirstName] = useState('');
85
57
  const [lastName, setLastName] = useState('');
86
58
  const [emailAddress, setEmailAddress] = useState('');
87
59
  const [password, setPassword] = useState('');
88
60
  const [state, setState] = useState<RegisterState>('form');
89
- const [resend, setResend] = useState<VerificationResendState>('idle');
90
61
  const [pending, setPending] = useState(false);
91
- const [resendPending, setResendPending] = useState(false);
92
62
 
93
63
  /**
94
64
  * The guard is the FIRST half of #1471's double-submit fix: the button is
@@ -115,19 +85,6 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
115
85
  setState(registerState(outcome));
116
86
  }
117
87
 
118
- /**
119
- * Same guard, and it matters more here: this button triggers a MAIL, so a
120
- * re-entrant click is a mail a shopper did not ask for. The platform throttles
121
- * it too — silently, which is why a refused resend still renders as sent.
122
- */
123
- async function requestAnotherMail(): Promise<void> {
124
- if (resendPending) return;
125
- setResendPending(true);
126
- const next = await submitVerificationRequest(emailAddress);
127
- setResendPending(false);
128
- setResend(next);
129
- }
130
-
131
88
  if (state === 'signed-in-already') {
132
89
  return (
133
90
  <div className="space-y-6" data-fc-account-mode="register" data-fc-account-state={state}>
@@ -135,8 +92,8 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
135
92
  This address is already confirmed, so there is nothing left to do here — you can sign in
136
93
  with it whenever you like.
137
94
  </AccountMessage>
138
- <LocaleLink href="/" locale={locale} className="btn btn-primary">
139
- Continue shopping
95
+ <LocaleLink href="/account" locale={locale} className="btn btn-primary">
96
+ Sign in
140
97
  </LocaleLink>
141
98
  </div>
142
99
  );
@@ -144,32 +101,17 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
144
101
 
145
102
  if (state === 'verify-your-email' || state === 'already-registered') {
146
103
  const prompt = PROMPTS[state];
147
- const resendMessage = resend === 'idle' ? null : RESEND_MESSAGES[resend];
148
104
  return (
149
105
  <div className="space-y-6" data-fc-account-mode="register" data-fc-account-state={state}>
150
106
  <AccountMessage tone={prompt.tone} title={prompt.title}>
151
107
  {prompt.body}
152
108
  </AccountMessage>
153
109
 
154
- {resendMessage && (
155
- <AccountMessage tone={resendMessage.tone} title={resendMessage.title}>
156
- {resendMessage.body}
157
- </AccountMessage>
158
- )}
159
-
160
- <div className="flex flex-wrap gap-3">
161
- <button
162
- type="button"
163
- className="btn btn-primary"
164
- disabled={resendPending}
165
- onClick={() => void requestAnotherMail()}
166
- >
167
- {resendPending ? 'Sending…' : 'Send the confirmation e-mail again'}
168
- </button>
110
+ <VerificationResend emailAddress={emailAddress}>
169
111
  <LocaleLink href="/" locale={locale} className="btn btn-ghost">
170
112
  Continue shopping
171
113
  </LocaleLink>
172
- </div>
114
+ </VerificationResend>
173
115
  </div>
174
116
  );
175
117
  }
@@ -278,6 +220,13 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
278
220
  <button type="submit" className="btn btn-primary" disabled={pending}>
279
221
  {pending ? 'Creating your account…' : 'Create account'}
280
222
  </button>
223
+
224
+ <p className="text-sm text-base-content/60">
225
+ Already have an account?{' '}
226
+ <LocaleLink href="/account" locale={locale} className="link link-hover">
227
+ Sign in
228
+ </LocaleLink>
229
+ </p>
281
230
  </form>
282
231
  );
283
232
  }
@@ -77,8 +77,8 @@ export function ResetPasswordForm({ token, locale }: { token: string; locale: Lo
77
77
  <AccountMessage tone="success" title="Your password has been changed">
78
78
  You can sign in with your new password from now on.
79
79
  </AccountMessage>
80
- <LocaleLink href="/" locale={locale} className="btn btn-primary">
81
- Continue shopping
80
+ <LocaleLink href="/account" locale={locale} className="btn btn-primary">
81
+ Sign in
82
82
  </LocaleLink>
83
83
  </div>
84
84
  );
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useState, type ReactNode } from 'react';
4
+
5
+ import { AccountMessage, type AccountMessageTone } from './AccountMessage';
6
+ import { submitVerificationRequest } from '../../lib/account-actions';
7
+ import type { VerificationResendState } from '../../lib/account/register-state';
8
+
9
+ /**
10
+ * Send the confirmation e-mail again (#1471, #1093) — the one key an UNCONFIRMED
11
+ * account's owner has. Rendered wherever such an account stops a shopper: the
12
+ * register prompt, for the address they just registered, and the sign-in form,
13
+ * after the API refused a sign-in NOT_VERIFIED. Without it that shopper has
14
+ * nowhere to go: the mail they lost is the only way to confirm the account.
15
+ *
16
+ * A Server Action rather than the shopper's socket: the resend needs nothing but
17
+ * an address, so it runs on the anonymous channel singleton like the reset
18
+ * request (`lib/account-actions.ts`).
19
+ *
20
+ * Renders the resend's answer, then a row holding the button and whatever the
21
+ * caller passes as `children` — its own next step beside it.
22
+ */
23
+
24
+ /**
25
+ * What a SUBMITTED resend says. `idle` has no entry because it is the
26
+ * pre-submit state and renders nothing.
27
+ *
28
+ * `sent` is worded so it is true for every address a shopper can type — one
29
+ * with a pending account, one with none, and one the platform's throttle
30
+ * silently swallowed. That is the same neutrality the reset-request form
31
+ * carries, and `verificationResendState` is where it is decided.
32
+ */
33
+ const RESEND_MESSAGES: Readonly<
34
+ Record<'sent' | 'failed', { tone: AccountMessageTone; title: string; body: string }>
35
+ > = {
36
+ sent: {
37
+ tone: 'success',
38
+ title: 'On its way',
39
+ body: 'If that address is waiting to be confirmed, a link is on its way to it. A mail we sent a moment ago still works, so it is worth checking for that one too.',
40
+ },
41
+ failed: {
42
+ tone: 'error',
43
+ title: 'We could not send that e-mail',
44
+ body: 'Something went wrong on our side and nothing has been sent. Please try again in a few minutes.',
45
+ },
46
+ };
47
+
48
+ export function VerificationResend({
49
+ emailAddress,
50
+ children,
51
+ }: {
52
+ /** The address the confirmation mail goes to. */
53
+ emailAddress: string;
54
+ /** The caller's own next step, rendered beside the button. */
55
+ children?: ReactNode;
56
+ }) {
57
+ const [resend, setResend] = useState<VerificationResendState>('idle');
58
+ const [pending, setPending] = useState(false);
59
+
60
+ /**
61
+ * This button triggers a MAIL, so a re-entrant click is a mail a shopper did
62
+ * not ask for: `pending` refuses one even before `disabled` has painted. The
63
+ * platform throttles it too — silently, which is why a refused resend still
64
+ * renders as sent.
65
+ */
66
+ async function requestAnotherMail(): Promise<void> {
67
+ if (pending) return;
68
+ setPending(true);
69
+ const next = await submitVerificationRequest(emailAddress);
70
+ setPending(false);
71
+ setResend(next);
72
+ }
73
+
74
+ const message = resend === 'idle' ? null : RESEND_MESSAGES[resend];
75
+ return (
76
+ <>
77
+ {message && (
78
+ <AccountMessage tone={message.tone} title={message.title}>
79
+ {message.body}
80
+ </AccountMessage>
81
+ )}
82
+
83
+ <div className="flex flex-wrap gap-3">
84
+ <button
85
+ type="button"
86
+ className="btn btn-primary"
87
+ disabled={pending}
88
+ onClick={() => void requestAnotherMail()}
89
+ >
90
+ {pending ? 'Sending…' : 'Send the confirmation e-mail again'}
91
+ </button>
92
+ {children}
93
+ </div>
94
+ </>
95
+ );
96
+ }
@@ -0,0 +1,152 @@
1
+ import type { AccountCallOutcome } from './account-link';
2
+
3
+ /**
4
+ * What `/account` renders, as a value (#1093).
5
+ *
6
+ * PURE BY CONTRACT, for the reasons `account-link.ts` states. The panel owns the
7
+ * COPY; this module owns which view a given answer of the shop API IS, so every
8
+ * decision the sign-in surface makes is a table of vectors and the panel is a
9
+ * render.
10
+ *
11
+ * The page is a static shell and the session lives on the shopper's own socket,
12
+ * so nothing here is known at render time: the server paints `loading`, and the
13
+ * panel asks the socket who is signed in once the cart provider has armed it.
14
+ */
15
+
16
+ /** The shopper a signed-in session belongs to, as the account page greets them. */
17
+ export interface SignedInCustomer {
18
+ readonly firstName: string;
19
+ readonly lastName: string;
20
+ readonly emailAddress: string;
21
+ }
22
+
23
+ /**
24
+ * What the shop API answered when asked who this socket's session belongs to.
25
+ *
26
+ * `customer: null` is an ordinary answer, not an error: a socket carrying no
27
+ * session, and an anonymous cart session, both belong to nobody.
28
+ */
29
+ export type ActiveCustomerOutcome =
30
+ | { kind: 'ok'; customer: SignedInCustomer | null }
31
+ | { kind: 'error'; code: string };
32
+
33
+ /** The call a refusal answered — what the panel says went wrong. */
34
+ export type AccountStep = 'greeting' | 'sign-in' | 'sign-out';
35
+
36
+ /** A refusal the panel renders, with the call it answered. */
37
+ export interface AccountRefusal {
38
+ readonly step: AccountStep;
39
+ readonly code: string;
40
+ }
41
+
42
+ /**
43
+ * The views of the account page — each `state` is the `data-fc-account-state`
44
+ * the panel stamps.
45
+ *
46
+ * - `loading` — the server render, and every client render until the
47
+ * socket has been asked. A skeleton, never the form: a
48
+ * returning customer must land on their greeting without a
49
+ * flash of the sign-in form first.
50
+ * - `signed-out` — the sign-in form, with the refusal that put it there, if
51
+ * any.
52
+ * - `signed-in` — the greeting and the way out.
53
+ */
54
+ export type AccountView =
55
+ | { readonly state: 'loading' }
56
+ | { readonly state: 'signed-out'; readonly refusal: AccountRefusal | null }
57
+ | { readonly state: 'signed-in'; readonly customer: SignedInCustomer };
58
+
59
+ /** The view the server renders. */
60
+ export const LOADING_VIEW: AccountView = { state: 'loading' };
61
+
62
+ /**
63
+ * The codes that mean the session the socket presented is OVER: the API holds
64
+ * no session for the token (`SESSION_NOT_FOUND`), or the request carried none
65
+ * it could use (`UNAUTHORIZED` — the guard's answer for a token that resolves
66
+ * to no session).
67
+ *
68
+ * A session that is over is a shopper who is signed out, not a fault: the
69
+ * greeting renders the form without a message, and a sign-out that finds the
70
+ * session already gone has done what it was asked.
71
+ */
72
+ const SESSION_GONE_CODES: ReadonlySet<string> = new Set(['UNAUTHORIZED', 'SESSION_NOT_FOUND']);
73
+
74
+ /**
75
+ * The refusal of a sign-in whose session greets nobody: the credentials were
76
+ * accepted, but the session belongs to no customer of THIS store — the code
77
+ * `/verify`'s `wrong-store` state reads for the same situation.
78
+ */
79
+ const NO_CUSTOMER_CODE = 'CUSTOMER_NOT_FOUND';
80
+
81
+ /** The refusal that a confirmation mail answers. */
82
+ const NOT_VERIFIED_CODE = 'NOT_VERIFIED';
83
+
84
+ /** Whether a refusal says the session it was asked about no longer exists. */
85
+ export function isSessionGone(code: string): boolean {
86
+ return SESSION_GONE_CODES.has(code);
87
+ }
88
+
89
+ /**
90
+ * The view the page lands in when it asks who is signed in on arrival.
91
+ *
92
+ * A session that greets nobody — none on the socket, an anonymous cart session,
93
+ * or one that is over — is the sign-in form with nothing to apologise for. Any
94
+ * other refusal is shown: the form still renders, because a shopper who cannot
95
+ * be greeted can still try to sign in.
96
+ */
97
+ export function greetingView(outcome: ActiveCustomerOutcome): AccountView {
98
+ if (outcome.kind === 'ok' && outcome.customer !== null) {
99
+ return { state: 'signed-in', customer: outcome.customer };
100
+ }
101
+ if (outcome.kind === 'ok' || isSessionGone(outcome.code)) {
102
+ return { state: 'signed-out', refusal: null };
103
+ }
104
+ return { state: 'signed-out', refusal: { step: 'greeting', code: outcome.code } };
105
+ }
106
+
107
+ /**
108
+ * The view an ACCEPTED sign-in lands in, from the greeting asked right after it.
109
+ *
110
+ * Unlike {@link greetingView}, "nobody is signed in" is never a silent answer
111
+ * here: the shopper has just been told yes, so a session that greets nobody is
112
+ * a failed sign-in and is rendered as one. A session that is gone the moment it
113
+ * was minted is the #1089 signature — a socket still speaking for the session
114
+ * it opened with — and is shown with its code rather than folded away.
115
+ */
116
+ export function signInView(outcome: ActiveCustomerOutcome): AccountView {
117
+ if (outcome.kind === 'error') {
118
+ return { state: 'signed-out', refusal: { step: 'sign-in', code: outcome.code } };
119
+ }
120
+ if (outcome.customer === null) {
121
+ return { state: 'signed-out', refusal: { step: 'sign-in', code: NO_CUSTOMER_CODE } };
122
+ }
123
+ return { state: 'signed-in', customer: outcome.customer };
124
+ }
125
+
126
+ /** The view a REFUSED sign-in lands in. */
127
+ export function signInRefusedView(code: string): AccountView {
128
+ return { state: 'signed-out', refusal: { step: 'sign-in', code } };
129
+ }
130
+
131
+ /**
132
+ * The view a sign-out lands in. Always `signed-out` — the session helper has
133
+ * left this tab without a session whatever the API answered — and a refusal is
134
+ * shown only when the API could not confirm the session ended.
135
+ */
136
+ export function signedOutView(outcome: AccountCallOutcome): AccountView {
137
+ if (outcome.kind === 'ok') return { state: 'signed-out', refusal: null };
138
+ return { state: 'signed-out', refusal: { step: 'sign-out', code: outcome.code } };
139
+ }
140
+
141
+ /**
142
+ * Whether the view offers to send the confirmation mail again: only after a
143
+ * sign-in the API refused because the account was never confirmed. Without it
144
+ * that shopper has nowhere to go — the mail they lost is the only key.
145
+ */
146
+ export function offersVerificationResend(view: AccountView): boolean {
147
+ return (
148
+ view.state === 'signed-out' &&
149
+ view.refusal?.step === 'sign-in' &&
150
+ view.refusal.code === NOT_VERIFIED_CODE
151
+ );
152
+ }
@@ -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
  }