@forgecart/cli 2.202610141814.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 (72) hide show
  1. package/dist/src/admin-api/admin-api.answer.d.ts +26 -0
  2. package/dist/src/admin-api/admin-api.answer.js +43 -0
  3. package/dist/src/admin-api/admin-api.answer.js.map +1 -0
  4. package/dist/src/admin-api/admin-api.client.d.ts +6 -4
  5. package/dist/src/admin-api/admin-api.client.js +19 -15
  6. package/dist/src/admin-api/admin-api.client.js.map +1 -1
  7. package/dist/src/admin-api/admin-api.result.d.ts +15 -9
  8. package/dist/src/admin-api/admin-api.result.js +45 -30
  9. package/dist/src/admin-api/admin-api.result.js.map +1 -1
  10. package/dist/src/authorization/cli-authorization.client.d.ts +4 -3
  11. package/dist/src/authorization/cli-authorization.client.js +18 -13
  12. package/dist/src/authorization/cli-authorization.client.js.map +1 -1
  13. package/dist/src/commands/sync.js +1 -1
  14. package/dist/src/commands/sync.js.map +1 -1
  15. package/dist/src/sync/sync-bundle-push.d.ts +15 -3
  16. package/dist/src/sync/sync-bundle-push.js +45 -2
  17. package/dist/src/sync/sync-bundle-push.js.map +1 -1
  18. package/dist/src/sync/sync-record.file.d.ts +27 -3
  19. package/dist/src/sync/sync-record.file.js +77 -25
  20. package/dist/src/sync/sync-record.file.js.map +1 -1
  21. package/dist/src/sync/sync-rejection.d.ts +24 -0
  22. package/dist/src/sync/sync-rejection.js +58 -0
  23. package/dist/src/sync/sync-rejection.js.map +1 -0
  24. package/dist/src/sync/sync-repository.d.ts +9 -1
  25. package/dist/src/sync/sync-repository.js +23 -1
  26. package/dist/src/sync/sync-repository.js.map +1 -1
  27. package/dist/src/sync/sync-round-follow.d.ts +42 -0
  28. package/dist/src/sync/sync-round-follow.js +128 -0
  29. package/dist/src/sync/sync-round-follow.js.map +1 -0
  30. package/dist/src/sync/sync-round.d.ts +49 -8
  31. package/dist/src/sync/sync-round.js +134 -27
  32. package/dist/src/sync/sync-round.js.map +1 -1
  33. package/dist/src/sync/sync.runner.d.ts +4 -2
  34. package/dist/src/sync/sync.runner.js +6 -3
  35. package/dist/src/sync/sync.runner.js.map +1 -1
  36. package/dist/src/sync/type/sync.types.d.ts +78 -2
  37. package/dist/src/sync/type/sync.types.js +31 -0
  38. package/dist/src/sync/type/sync.types.js.map +1 -1
  39. package/dist/src/sync/workspace-sync.client.d.ts +29 -6
  40. package/dist/src/sync/workspace-sync.client.js +141 -21
  41. package/dist/src/sync/workspace-sync.client.js.map +1 -1
  42. package/package.json +1 -1
  43. package/templates/storefront/README.md +52 -5
  44. package/templates/storefront/src/app/account/page.tsx +51 -0
  45. package/templates/storefront/src/app/layout.tsx +4 -5
  46. package/templates/storefront/src/app/verify/page.tsx +16 -4
  47. package/templates/storefront/src/components/Header.tsx +13 -1
  48. package/templates/storefront/src/components/account/AccountPanel.tsx +289 -0
  49. package/templates/storefront/src/components/account/RegisterForm.tsx +15 -66
  50. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +2 -2
  51. package/templates/storefront/src/components/account/VerificationResend.tsx +96 -0
  52. package/templates/storefront/src/lib/account/account-state.ts +152 -0
  53. package/templates/storefront/src/lib/account-session.ts +83 -16
  54. package/templates/storefront/src/lib/error-messages.ts +102 -8
  55. package/templates/storefront/src/lib/seo/noindex.ts +5 -0
  56. package/templates/storefront/src/lib/seo/sitemap-entries.ts +2 -0
  57. package/templates/storefront/src/lib/shop-session.ts +71 -18
  58. package/templates/storefront-shadcn/README.md +52 -5
  59. package/templates/storefront-shadcn/src/app/account/page.tsx +51 -0
  60. package/templates/storefront-shadcn/src/app/layout.tsx +4 -5
  61. package/templates/storefront-shadcn/src/app/verify/page.tsx +16 -4
  62. package/templates/storefront-shadcn/src/components/Header.tsx +17 -1
  63. package/templates/storefront-shadcn/src/components/account/AccountPanel.tsx +290 -0
  64. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +19 -61
  65. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +2 -2
  66. package/templates/storefront-shadcn/src/components/account/VerificationResend.tsx +92 -0
  67. package/templates/storefront-shadcn/src/lib/account/account-state.ts +152 -0
  68. package/templates/storefront-shadcn/src/lib/account-session.ts +83 -16
  69. package/templates/storefront-shadcn/src/lib/error-messages.ts +102 -8
  70. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +5 -0
  71. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +2 -0
  72. package/templates/storefront-shadcn/src/lib/shop-session.ts +71 -18
@@ -68,39 +68,51 @@ export async function generateMetadata(): Promise<Metadata> {
68
68
  * first thing a rebrand rewrites, while the state set is the contract.
69
69
  */
70
70
 
71
- /** What each outcome tells the shopper, and where it sends them next. */
71
+ /**
72
+ * What each outcome tells the shopper, and where it sends them next. A verified
73
+ * account's next step is signing in (#1093), so that state links the account
74
+ * page; every other state sends the shopper back to the store.
75
+ */
72
76
  const VERIFY_COPY: Readonly<
73
- Record<VerifyState, { tone: AccountMessageTone; title: string; body: string; cta: string }>
77
+ Record<
78
+ VerifyState,
79
+ { tone: AccountMessageTone; title: string; body: string; cta: string; href: string }
80
+ >
74
81
  > = {
75
82
  verified: {
76
83
  tone: 'success',
77
84
  title: 'Your e-mail address is verified',
78
85
  body: 'Your account is ready — you can sign in with the address you registered.',
79
- cta: 'Start shopping',
86
+ cta: 'Sign in',
87
+ href: '/account',
80
88
  },
81
89
  'no-token': {
82
90
  tone: 'warning',
83
91
  title: 'This link is missing its verification token',
84
92
  body: 'Open the link straight from the e-mail we sent you. It carries a one-time token this page needs, and copying only part of the address leaves it behind.',
85
93
  cta: 'Go to the store',
94
+ href: '/',
86
95
  },
87
96
  'link-invalid': {
88
97
  tone: 'warning',
89
98
  title: 'This verification link is no longer valid',
90
99
  body: 'It has either been used already or it has expired. If your account is still unverified, request a new verification e-mail and open the newest link.',
91
100
  cta: 'Go to the store',
101
+ href: '/',
92
102
  },
93
103
  'wrong-store': {
94
104
  tone: 'warning',
95
105
  title: 'This link belongs to a different store',
96
106
  body: 'The account it verifies does not shop here. Open the link from the e-mail that store sent you — every store sends its own.',
97
107
  cta: 'Go to the store',
108
+ href: '/',
98
109
  },
99
110
  failed: {
100
111
  tone: 'error',
101
112
  title: 'We could not verify your e-mail address',
102
113
  body: 'Something went wrong on our side. Please open the link again in a few minutes — and if it has stopped working by then, request a new verification e-mail.',
103
114
  cta: 'Go to the store',
115
+ href: '/',
104
116
  },
105
117
  };
106
118
 
@@ -138,7 +150,7 @@ async function VerifyOutcome({ searchParams }: { searchParams: Promise<RouteSear
138
150
  <AccountMessage tone={copy.tone} title={copy.title}>
139
151
  {copy.body}
140
152
  </AccountMessage>
141
- <LocaleLink href="/" locale={binding} className="btn btn-primary">
153
+ <LocaleLink href={copy.href} locale={binding} className="btn btn-primary">
142
154
  {copy.cta}
143
155
  </LocaleLink>
144
156
  </div>
@@ -19,7 +19,16 @@ export interface HeaderProps {
19
19
  languageCodes: readonly string[];
20
20
  }
21
21
 
22
- /** Top navigation: Home / Products / Cart, with a live cart item count. */
22
+ /**
23
+ * Top navigation: Home / Products / Cart / Account, with a live cart item count.
24
+ *
25
+ * Every link names a route this template SHIPS (#1093): a link to a page the
26
+ * scaffold does not carry is a 404 for the shopper and a false lead for the
27
+ * agent extending the store, and `tool/storefront-template-spec` holds this
28
+ * file to it. The Account link is static — it never renders who is signed in —
29
+ * so nothing here depends on the session and no promote can trip a hydration
30
+ * mismatch on it; the account page itself says who is signed in.
31
+ */
23
32
  export function Header({ locale, languageCodes }: HeaderProps) {
24
33
  const { itemCount } = useCart();
25
34
 
@@ -57,6 +66,9 @@ export function Header({ locale, languageCodes }: HeaderProps) {
57
66
  <span className="badge badge-primary badge-sm font-semibold">{itemCount}</span>
58
67
  )}
59
68
  </LocaleLink>
69
+ <LocaleLink href="/account" locale={locale} className="btn btn-ghost btn-sm">
70
+ Account
71
+ </LocaleLink>
60
72
  <LanguageSwitcher locale={locale} languageCodes={languageCodes} />
61
73
  </nav>
62
74
  </div>
@@ -0,0 +1,289 @@
1
+ 'use client';
2
+
3
+ import type { NativeAuthInput } from '@forgecart/sdk/shop';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ import { AccountMessage, type AccountMessageTone } from './AccountMessage';
7
+ import { VerificationResend } from './VerificationResend';
8
+ import { LocaleLink } from '../LocaleLink';
9
+ import { findSignedInCustomer, loginCustomer, logoutCustomer } from '../../lib/account-session';
10
+ import {
11
+ LOADING_VIEW,
12
+ greetingView,
13
+ offersVerificationResend,
14
+ signInRefusedView,
15
+ signInView,
16
+ signedOutView,
17
+ type AccountRefusal,
18
+ type AccountStep,
19
+ type AccountView,
20
+ type SignedInCustomer,
21
+ } from '../../lib/account/account-state';
22
+ import { useCart } from '../../lib/cart-context';
23
+ import { shopperMessage } from '../../lib/error-messages';
24
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
25
+
26
+ /**
27
+ * The account page's client island (#1093): the sign-in form, the greeting, and
28
+ * the way out.
29
+ *
30
+ * Everything here runs on the shopper's OWN socket, never on the server: the
31
+ * page around it is a static shell, because a session read on the server path
32
+ * threw `EmptyError: no elements in sequence` and 500'd the render. Signing in
33
+ * and out goes through `lib/account-session.ts`, whose helpers own the socket's
34
+ * rotation — a panel cannot get it wrong, because it never touches the socket.
35
+ *
36
+ * The island waits for the cart provider's `hydrated` before it asks anything.
37
+ * The provider is what arms the session singleton, and a parent's effects run
38
+ * AFTER its children's: an island that asked on its own mount would find no
39
+ * socket. Until then it renders the skeleton the server painted, so a returning
40
+ * customer lands on their greeting without a flash of the sign-in form first.
41
+ *
42
+ * Which view an answer IS is decided in `lib/account/account-state.ts`, and
43
+ * every refusal renders through `shopperMessage` — this component renders.
44
+ */
45
+
46
+ /** How each call's refusal is introduced; the body is `shopperMessage`'s copy. */
47
+ const REFUSAL_HEADINGS: Readonly<Record<AccountStep, { tone: AccountMessageTone; title: string }>> =
48
+ {
49
+ greeting: { tone: 'error', title: 'We could not check your account' },
50
+ 'sign-in': { tone: 'error', title: 'We could not sign you in' },
51
+ 'sign-out': { tone: 'warning', title: 'You are signed out on this device' },
52
+ };
53
+
54
+ /** The message a refusal renders as, or nothing when the form has nothing to report. */
55
+ function refusalNotice(
56
+ refusal: AccountRefusal | null,
57
+ ): { tone: AccountMessageTone; title: string; body: string } | null {
58
+ if (refusal === null) return null;
59
+ return { ...REFUSAL_HEADINGS[refusal.step], body: shopperMessage(refusal) };
60
+ }
61
+
62
+ export function AccountPanel({ locale }: { locale: LocaleBinding }) {
63
+ const { hydrated, refresh } = useCart();
64
+ const [view, setView] = useState<AccountView>(LOADING_VIEW);
65
+ const [refusedAddress, setRefusedAddress] = useState('');
66
+ const [pending, setPending] = useState(false);
67
+
68
+ useEffect(() => {
69
+ if (!hydrated) return;
70
+ let cancelled = false;
71
+ void (async () => {
72
+ const outcome = await findSignedInCustomer();
73
+ if (!cancelled) setView(greetingView(outcome));
74
+ })();
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [hydrated]);
79
+
80
+ /**
81
+ * `pending` refuses a re-entrant call: a keyboard Enter can fire while a click
82
+ * is already in flight, before `disabled` has painted. A sign-in that lands
83
+ * re-reads the cart, because the session it belongs to is now the shopper's.
84
+ * An accepted sign-in whose greeting fails is signed out again before the
85
+ * refusal renders: the tab never stays signed in behind a failed sign-in.
86
+ */
87
+ async function signIn(credentials: NativeAuthInput): Promise<void> {
88
+ if (pending) return;
89
+ setPending(true);
90
+ const outcome = await loginCustomer(credentials);
91
+ if (outcome.kind === 'error') {
92
+ setRefusedAddress(credentials.emailAddress);
93
+ setView(signInRefusedView(outcome.code));
94
+ setPending(false);
95
+ return;
96
+ }
97
+ const next = signInView(await findSignedInCustomer());
98
+ if (next.state !== 'signed-in') await logoutCustomer();
99
+ refresh();
100
+ setView(next);
101
+ setPending(false);
102
+ }
103
+
104
+ /** Same guard. The cart is re-read: this tab no longer carries the session it belonged to. */
105
+ async function signOut(): Promise<void> {
106
+ if (pending) return;
107
+ setPending(true);
108
+ const outcome = await logoutCustomer();
109
+ refresh();
110
+ setView(signedOutView(outcome));
111
+ setPending(false);
112
+ }
113
+
114
+ if (view.state === 'loading') return <AccountSkeleton />;
115
+
116
+ if (view.state === 'signed-in') {
117
+ return (
118
+ <Greeting
119
+ customer={view.customer}
120
+ locale={locale}
121
+ pending={pending}
122
+ onSignOut={() => void signOut()}
123
+ />
124
+ );
125
+ }
126
+
127
+ return (
128
+ <SignInForm
129
+ locale={locale}
130
+ refusal={view.refusal}
131
+ resendTo={offersVerificationResend(view) ? refusedAddress : null}
132
+ pending={pending}
133
+ onSignIn={signIn}
134
+ />
135
+ );
136
+ }
137
+
138
+ function Greeting({
139
+ customer,
140
+ locale,
141
+ pending,
142
+ onSignOut,
143
+ }: {
144
+ customer: SignedInCustomer;
145
+ locale: LocaleBinding;
146
+ pending: boolean;
147
+ onSignOut: () => void;
148
+ }) {
149
+ return (
150
+ <div className="space-y-6" data-fc-account-mode="account" data-fc-account-state="signed-in">
151
+ <div className="card border border-base-300 bg-base-100">
152
+ <div className="card-body">
153
+ <h2 className="card-title">Hello, {customer.firstName}</h2>
154
+ <p className="text-sm text-base-content/70">
155
+ You are signed in as {customer.emailAddress}.
156
+ </p>
157
+ </div>
158
+ </div>
159
+
160
+ <div className="flex flex-wrap gap-3">
161
+ <LocaleLink href="/products" locale={locale} className="btn btn-primary">
162
+ Continue shopping
163
+ </LocaleLink>
164
+ <button type="button" className="btn btn-ghost" disabled={pending} onClick={onSignOut}>
165
+ {pending ? 'Signing out…' : 'Sign out'}
166
+ </button>
167
+ </div>
168
+ </div>
169
+ );
170
+ }
171
+
172
+ function SignInForm({
173
+ locale,
174
+ refusal,
175
+ resendTo,
176
+ pending,
177
+ onSignIn,
178
+ }: {
179
+ locale: LocaleBinding;
180
+ /** The refusal that put the form here, if any. */
181
+ refusal: AccountRefusal | null;
182
+ /** The address to offer the confirmation mail to, when the refusal calls for one. */
183
+ resendTo: string | null;
184
+ pending: boolean;
185
+ onSignIn: (credentials: NativeAuthInput) => Promise<void>;
186
+ }) {
187
+ const [emailAddress, setEmailAddress] = useState('');
188
+ const [password, setPassword] = useState('');
189
+
190
+ async function submit(): Promise<void> {
191
+ await onSignIn({ emailAddress, password });
192
+ // The password only ever lived in this component's state; drop it as soon
193
+ // as the answer is in, whatever the answer was.
194
+ setPassword('');
195
+ }
196
+
197
+ const notice = refusalNotice(refusal);
198
+ return (
199
+ <form
200
+ className="space-y-4"
201
+ data-fc-account-mode="account"
202
+ data-fc-account-state="signed-out"
203
+ onSubmit={(event) => {
204
+ event.preventDefault();
205
+ void submit();
206
+ }}
207
+ >
208
+ <p className="text-sm text-base-content/60">
209
+ Sign in with the e-mail address and password you registered with.
210
+ </p>
211
+
212
+ {notice && (
213
+ <AccountMessage tone={notice.tone} title={notice.title}>
214
+ {notice.body}
215
+ </AccountMessage>
216
+ )}
217
+
218
+ {resendTo !== null && <VerificationResend key={resendTo} emailAddress={resendTo} />}
219
+
220
+ <div>
221
+ <label
222
+ className="mb-1 block text-sm font-medium text-base-content/70"
223
+ htmlFor="sign-in-email"
224
+ >
225
+ E-mail address
226
+ </label>
227
+ <input
228
+ id="sign-in-email"
229
+ type="email"
230
+ required
231
+ autoComplete="email"
232
+ value={emailAddress}
233
+ onChange={(event) => setEmailAddress(event.target.value)}
234
+ className="input w-full"
235
+ />
236
+ </div>
237
+
238
+ <div>
239
+ <label
240
+ className="mb-1 block text-sm font-medium text-base-content/70"
241
+ htmlFor="sign-in-password"
242
+ >
243
+ Password
244
+ </label>
245
+ <input
246
+ id="sign-in-password"
247
+ type="password"
248
+ required
249
+ autoComplete="current-password"
250
+ value={password}
251
+ onChange={(event) => setPassword(event.target.value)}
252
+ className="input w-full"
253
+ />
254
+ </div>
255
+
256
+ <div className="flex flex-wrap items-center gap-4">
257
+ <button type="submit" className="btn btn-primary" disabled={pending}>
258
+ {pending ? 'Signing in…' : 'Sign in'}
259
+ </button>
260
+ <LocaleLink href="/reset-password" locale={locale} className="link link-hover text-sm">
261
+ Forgot your password?
262
+ </LocaleLink>
263
+ </div>
264
+
265
+ <p className="text-sm text-base-content/60">
266
+ New here?{' '}
267
+ <LocaleLink href="/register" locale={locale} className="link link-hover">
268
+ Create an account
269
+ </LocaleLink>
270
+ </p>
271
+ </form>
272
+ );
273
+ }
274
+
275
+ function AccountSkeleton() {
276
+ return (
277
+ <div
278
+ className="space-y-4"
279
+ aria-busy="true"
280
+ aria-label="Loading your account"
281
+ data-fc-account-mode="account"
282
+ data-fc-account-state="loading"
283
+ >
284
+ <div className="skeleton h-10 w-full"></div>
285
+ <div className="skeleton h-10 w-full"></div>
286
+ <div className="skeleton h-10 w-32"></div>
287
+ </div>
288
+ );
289
+ }
@@ -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
+ }