@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
@@ -0,0 +1,51 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ import { AccountPanel } from '../../components/account/AccountPanel';
4
+ import { getRequestLocale } from '../../lib/locale/request-binding';
5
+ import { routeMetadata } from '../../lib/seo/metadata';
6
+
7
+ /**
8
+ * `/account` — where a shopper signs in, is greeted, and signs out (#1093).
9
+ *
10
+ * A STATIC SHELL, and that is the law this file exists to keep: the server
11
+ * reads no session here — no cookie, no `connection()`, no session module. A
12
+ * storefront agent once read the session on the server path; the render threw
13
+ * `EmptyError: no elements in sequence`, and the 500 made the promote gate roll
14
+ * back the whole changeset. The session is the shopper's, on their own socket:
15
+ * `AccountPanel` asks it in the browser, and the server paints the skeleton it
16
+ * starts from. `tool/storefront-template-spec` holds this file to that.
17
+ *
18
+ * Locale-prefixable like `/register` — a shopper addresses it from a link on
19
+ * this storefront, never out of a mail — so `account` is absent from the
20
+ * grammar's unprefixed set and `/de/account` serves the German render.
21
+ */
22
+
23
+ const PAGE_TITLE = 'Your account';
24
+
25
+ /**
26
+ * Noindex via `NOINDEX_PATHS`, and no alternates — for the reasons `/register`'s
27
+ * metadata states: one shopper's own page has nothing for a search result to be
28
+ * about, and a page that declines indexing has no preferred address to claim.
29
+ */
30
+ export async function generateMetadata(): Promise<Metadata> {
31
+ const { binding, shopName, channelResolved } = await getRequestLocale();
32
+ return routeMetadata({
33
+ binding,
34
+ shopName,
35
+ channelResolved,
36
+ pathname: '/account',
37
+ pathsByLocale: {},
38
+ title: PAGE_TITLE,
39
+ });
40
+ }
41
+
42
+ export default async function AccountPage() {
43
+ const { binding } = await getRequestLocale();
44
+
45
+ return (
46
+ <div className="mx-auto max-w-xl space-y-6">
47
+ <h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
48
+ <AccountPanel locale={binding} />
49
+ </div>
50
+ );
51
+ }
@@ -149,11 +149,10 @@ export default async function RootLayout({ children }: { children: ReactNode })
149
149
  >
150
150
  Cart
151
151
  </LocaleLink>
152
- {/* The only affordance for `/register` (#1471). It sits in the
153
- footer rather than the header nav on purpose: the template
154
- ships no sign-in surface yet, and an account entry in the
155
- primary nav would advertise an account AREA that does not
156
- exist. A merchant who adds sign-in moves it up. */}
152
+ {/* The direct way to `/register` (#1471). The header's Account
153
+ link (#1093) is the account entry — sign-in, with
154
+ registration one link away — and this is the shortcut for
155
+ a shopper who already knows they are new. */}
157
156
  <LocaleLink
158
157
  href="/register"
159
158
  locale={binding}
@@ -70,39 +70,51 @@ export async function generateMetadata(): Promise<Metadata> {
70
70
  * first thing a rebrand rewrites, while the state set is the contract.
71
71
  */
72
72
 
73
- /** What each outcome tells the shopper, and where it sends them next. */
73
+ /**
74
+ * What each outcome tells the shopper, and where it sends them next. A verified
75
+ * account's next step is signing in (#1093), so that state links the account
76
+ * page; every other state sends the shopper back to the store.
77
+ */
74
78
  const VERIFY_COPY: Readonly<
75
- Record<VerifyState, { tone: AccountMessageTone; title: string; body: string; cta: string }>
79
+ Record<
80
+ VerifyState,
81
+ { tone: AccountMessageTone; title: string; body: string; cta: string; href: string }
82
+ >
76
83
  > = {
77
84
  verified: {
78
85
  tone: 'success',
79
86
  title: 'Your e-mail address is verified',
80
87
  body: 'Your account is ready — you can sign in with the address you registered.',
81
- cta: 'Start shopping',
88
+ cta: 'Sign in',
89
+ href: '/account',
82
90
  },
83
91
  'no-token': {
84
92
  tone: 'warning',
85
93
  title: 'This link is missing its verification token',
86
94
  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.',
87
95
  cta: 'Go to the store',
96
+ href: '/',
88
97
  },
89
98
  'link-invalid': {
90
99
  tone: 'warning',
91
100
  title: 'This verification link is no longer valid',
92
101
  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.',
93
102
  cta: 'Go to the store',
103
+ href: '/',
94
104
  },
95
105
  'wrong-store': {
96
106
  tone: 'warning',
97
107
  title: 'This link belongs to a different store',
98
108
  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.',
99
109
  cta: 'Go to the store',
110
+ href: '/',
100
111
  },
101
112
  failed: {
102
113
  tone: 'error',
103
114
  title: 'We could not verify your e-mail address',
104
115
  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.',
105
116
  cta: 'Go to the store',
117
+ href: '/',
106
118
  },
107
119
  };
108
120
 
@@ -140,7 +152,7 @@ async function VerifyOutcome({ searchParams }: { searchParams: Promise<RouteSear
140
152
  <AccountMessage tone={copy.tone} title={copy.title}>
141
153
  {copy.body}
142
154
  </AccountMessage>
143
- <LocaleLink href="/" locale={binding} className={buttonVariants()}>
155
+ <LocaleLink href={copy.href} locale={binding} className={buttonVariants()}>
144
156
  {copy.cta}
145
157
  </LocaleLink>
146
158
  </div>
@@ -21,7 +21,16 @@ export interface HeaderProps {
21
21
  languageCodes: readonly string[];
22
22
  }
23
23
 
24
- /** Top navigation: Home / Products / Cart, with a live cart item count. */
24
+ /**
25
+ * Top navigation: Home / Products / Cart / Account, with a live cart item count.
26
+ *
27
+ * Every link names a route this template SHIPS (#1093): a link to a page the
28
+ * scaffold does not carry is a 404 for the shopper and a false lead for the
29
+ * agent extending the store, and `tool/storefront-template-spec` holds this
30
+ * file to it. The Account link is static — it never renders who is signed in —
31
+ * so nothing here depends on the session and no promote can trip a hydration
32
+ * mismatch on it; the account page itself says who is signed in.
33
+ */
25
34
  export function Header({ locale, languageCodes }: HeaderProps) {
26
35
  const { itemCount } = useCart();
27
36
 
@@ -65,6 +74,13 @@ export function Header({ locale, languageCodes }: HeaderProps) {
65
74
  Cart
66
75
  {mounted && itemCount > 0 && <Badge className="font-semibold">{itemCount}</Badge>}
67
76
  </LocaleLink>
77
+ <LocaleLink
78
+ href="/account"
79
+ locale={locale}
80
+ className={buttonVariants({ variant: 'ghost', size: 'sm' })}
81
+ >
82
+ Account
83
+ </LocaleLink>
68
84
  <LanguageSwitcher locale={locale} languageCodes={languageCodes} />
69
85
  </nav>
70
86
  </div>
@@ -0,0 +1,290 @@
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 { Button, buttonVariants } from '../ui/button';
10
+ import { Card, CardContent } from '../ui/card';
11
+ import { Input } from '../ui/input';
12
+ import { Label } from '../ui/label';
13
+ import { Skeleton } from '../ui/skeleton';
14
+ import { findSignedInCustomer, loginCustomer, logoutCustomer } from '../../lib/account-session';
15
+ import {
16
+ LOADING_VIEW,
17
+ greetingView,
18
+ offersVerificationResend,
19
+ signInRefusedView,
20
+ signInView,
21
+ signedOutView,
22
+ type AccountRefusal,
23
+ type AccountStep,
24
+ type AccountView,
25
+ type SignedInCustomer,
26
+ } from '../../lib/account/account-state';
27
+ import { useCart } from '../../lib/cart-context';
28
+ import { shopperMessage } from '../../lib/error-messages';
29
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
30
+
31
+ /**
32
+ * The account page's client island (#1093): the sign-in form, the greeting, and
33
+ * the way out.
34
+ *
35
+ * Everything here runs on the shopper's OWN socket, never on the server: the
36
+ * page around it is a static shell, because a session read on the server path
37
+ * threw `EmptyError: no elements in sequence` and 500'd the render. Signing in
38
+ * and out goes through `lib/account-session.ts`, whose helpers own the socket's
39
+ * rotation — a panel cannot get it wrong, because it never touches the socket.
40
+ *
41
+ * The island waits for the cart provider's `hydrated` before it asks anything.
42
+ * The provider is what arms the session singleton, and a parent's effects run
43
+ * AFTER its children's: an island that asked on its own mount would find no
44
+ * socket. Until then it renders the skeleton the server painted, so a returning
45
+ * customer lands on their greeting without a flash of the sign-in form first.
46
+ *
47
+ * Which view an answer IS is decided in `lib/account/account-state.ts`, and
48
+ * every refusal renders through `shopperMessage` — this component renders.
49
+ */
50
+
51
+ /** How each call's refusal is introduced; the body is `shopperMessage`'s copy. */
52
+ const REFUSAL_HEADINGS: Readonly<Record<AccountStep, { tone: AccountMessageTone; title: string }>> =
53
+ {
54
+ greeting: { tone: 'error', title: 'We could not check your account' },
55
+ 'sign-in': { tone: 'error', title: 'We could not sign you in' },
56
+ 'sign-out': { tone: 'warning', title: 'You are signed out on this device' },
57
+ };
58
+
59
+ /** The message a refusal renders as, or nothing when the form has nothing to report. */
60
+ function refusalNotice(
61
+ refusal: AccountRefusal | null,
62
+ ): { tone: AccountMessageTone; title: string; body: string } | null {
63
+ if (refusal === null) return null;
64
+ return { ...REFUSAL_HEADINGS[refusal.step], body: shopperMessage(refusal) };
65
+ }
66
+
67
+ export function AccountPanel({ locale }: { locale: LocaleBinding }) {
68
+ const { hydrated, refresh } = useCart();
69
+ const [view, setView] = useState<AccountView>(LOADING_VIEW);
70
+ const [refusedAddress, setRefusedAddress] = useState('');
71
+ const [pending, setPending] = useState(false);
72
+
73
+ useEffect(() => {
74
+ if (!hydrated) return;
75
+ let cancelled = false;
76
+ void (async () => {
77
+ const outcome = await findSignedInCustomer();
78
+ if (!cancelled) setView(greetingView(outcome));
79
+ })();
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [hydrated]);
84
+
85
+ /**
86
+ * `pending` refuses a re-entrant call: a keyboard Enter can fire while a click
87
+ * is already in flight, before `disabled` has painted. A sign-in that lands
88
+ * re-reads the cart, because the session it belongs to is now the shopper's.
89
+ * An accepted sign-in whose greeting fails is signed out again before the
90
+ * refusal renders: the tab never stays signed in behind a failed sign-in.
91
+ */
92
+ async function signIn(credentials: NativeAuthInput): Promise<void> {
93
+ if (pending) return;
94
+ setPending(true);
95
+ const outcome = await loginCustomer(credentials);
96
+ if (outcome.kind === 'error') {
97
+ setRefusedAddress(credentials.emailAddress);
98
+ setView(signInRefusedView(outcome.code));
99
+ setPending(false);
100
+ return;
101
+ }
102
+ const next = signInView(await findSignedInCustomer());
103
+ if (next.state !== 'signed-in') await logoutCustomer();
104
+ refresh();
105
+ setView(next);
106
+ setPending(false);
107
+ }
108
+
109
+ /** Same guard. The cart is re-read: this tab no longer carries the session it belonged to. */
110
+ async function signOut(): Promise<void> {
111
+ if (pending) return;
112
+ setPending(true);
113
+ const outcome = await logoutCustomer();
114
+ refresh();
115
+ setView(signedOutView(outcome));
116
+ setPending(false);
117
+ }
118
+
119
+ if (view.state === 'loading') return <AccountSkeleton />;
120
+
121
+ if (view.state === 'signed-in') {
122
+ return (
123
+ <Greeting
124
+ customer={view.customer}
125
+ locale={locale}
126
+ pending={pending}
127
+ onSignOut={() => void signOut()}
128
+ />
129
+ );
130
+ }
131
+
132
+ return (
133
+ <SignInForm
134
+ locale={locale}
135
+ refusal={view.refusal}
136
+ resendTo={offersVerificationResend(view) ? refusedAddress : null}
137
+ pending={pending}
138
+ onSignIn={signIn}
139
+ />
140
+ );
141
+ }
142
+
143
+ function Greeting({
144
+ customer,
145
+ locale,
146
+ pending,
147
+ onSignOut,
148
+ }: {
149
+ customer: SignedInCustomer;
150
+ locale: LocaleBinding;
151
+ pending: boolean;
152
+ onSignOut: () => void;
153
+ }) {
154
+ return (
155
+ <div className="space-y-6" data-fc-account-mode="account" data-fc-account-state="signed-in">
156
+ <Card>
157
+ <CardContent className="space-y-2">
158
+ <h2 className="text-lg font-semibold">Hello, {customer.firstName}</h2>
159
+ <p className="text-sm text-muted-foreground">
160
+ You are signed in as {customer.emailAddress}.
161
+ </p>
162
+ </CardContent>
163
+ </Card>
164
+
165
+ <div className="flex flex-wrap gap-3">
166
+ <LocaleLink href="/products" locale={locale} className={buttonVariants()}>
167
+ Continue shopping
168
+ </LocaleLink>
169
+ <Button type="button" variant="ghost" disabled={pending} onClick={onSignOut}>
170
+ {pending ? 'Signing out…' : 'Sign out'}
171
+ </Button>
172
+ </div>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ function SignInForm({
178
+ locale,
179
+ refusal,
180
+ resendTo,
181
+ pending,
182
+ onSignIn,
183
+ }: {
184
+ locale: LocaleBinding;
185
+ /** The refusal that put the form here, if any. */
186
+ refusal: AccountRefusal | null;
187
+ /** The address to offer the confirmation mail to, when the refusal calls for one. */
188
+ resendTo: string | null;
189
+ pending: boolean;
190
+ onSignIn: (credentials: NativeAuthInput) => Promise<void>;
191
+ }) {
192
+ const [emailAddress, setEmailAddress] = useState('');
193
+ const [password, setPassword] = useState('');
194
+
195
+ async function submit(): Promise<void> {
196
+ await onSignIn({ emailAddress, password });
197
+ // The password only ever lived in this component's state; drop it as soon
198
+ // as the answer is in, whatever the answer was.
199
+ setPassword('');
200
+ }
201
+
202
+ const notice = refusalNotice(refusal);
203
+ return (
204
+ <form
205
+ className="space-y-4"
206
+ data-fc-account-mode="account"
207
+ data-fc-account-state="signed-out"
208
+ onSubmit={(event) => {
209
+ event.preventDefault();
210
+ void submit();
211
+ }}
212
+ >
213
+ <p className="text-sm text-muted-foreground">
214
+ Sign in with the e-mail address and password you registered with.
215
+ </p>
216
+
217
+ {notice && (
218
+ <AccountMessage tone={notice.tone} title={notice.title}>
219
+ {notice.body}
220
+ </AccountMessage>
221
+ )}
222
+
223
+ {resendTo !== null && <VerificationResend key={resendTo} emailAddress={resendTo} />}
224
+
225
+ <div className="grid gap-2">
226
+ <Label htmlFor="sign-in-email">E-mail address</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
+ />
235
+ </div>
236
+
237
+ <div className="grid gap-2">
238
+ <Label htmlFor="sign-in-password">Password</Label>
239
+ <Input
240
+ id="sign-in-password"
241
+ type="password"
242
+ required
243
+ autoComplete="current-password"
244
+ value={password}
245
+ onChange={(event) => setPassword(event.target.value)}
246
+ />
247
+ </div>
248
+
249
+ <div className="flex flex-wrap items-center gap-4">
250
+ <Button type="submit" disabled={pending}>
251
+ {pending ? 'Signing in…' : 'Sign in'}
252
+ </Button>
253
+ <LocaleLink
254
+ href="/reset-password"
255
+ locale={locale}
256
+ className="text-sm underline-offset-4 hover:text-foreground hover:underline"
257
+ >
258
+ Forgot your password?
259
+ </LocaleLink>
260
+ </div>
261
+
262
+ <p className="text-sm text-muted-foreground">
263
+ New here?{' '}
264
+ <LocaleLink
265
+ href="/register"
266
+ locale={locale}
267
+ className="font-medium text-primary underline-offset-4 hover:underline"
268
+ >
269
+ Create an account
270
+ </LocaleLink>
271
+ </p>
272
+ </form>
273
+ );
274
+ }
275
+
276
+ function AccountSkeleton() {
277
+ return (
278
+ <div
279
+ className="space-y-4"
280
+ aria-busy="true"
281
+ aria-label="Loading your account"
282
+ data-fc-account-mode="account"
283
+ data-fc-account-state="loading"
284
+ >
285
+ <Skeleton className="h-10 w-full" />
286
+ <Skeleton className="h-10 w-full" />
287
+ <Skeleton className="h-10 w-32" />
288
+ </div>
289
+ );
290
+ }
@@ -3,17 +3,13 @@
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
8
  import { Button, buttonVariants } from '../ui/button';
8
9
  import { Input } from '../ui/input';
9
10
  import { Label } from '../ui/label';
10
- import { submitVerificationRequest } from '../../lib/account-actions';
11
11
  import { registerCustomerAccount } from '../../lib/account-session';
12
- import {
13
- registerState,
14
- type RegisterState,
15
- type VerificationResendState,
16
- } from '../../lib/account/register-state';
12
+ import { registerState, type RegisterState } from '../../lib/account/register-state';
17
13
  import type { LocaleBinding } from '../../lib/locale/localized-path';
18
14
 
19
15
  /**
@@ -29,8 +25,8 @@ import type { LocaleBinding } from '../../lib/locale/localized-path';
29
25
  * The submit runs on the shopper's OWN websocket rather than through a Server
30
26
  * Action, because the API's protection against a double-clicked submit is
31
27
  * scoped to their session — `lib/account-session.ts` carries the full
32
- * argument. The RESEND below is a Server Action, because it needs nothing but
33
- * an address.
28
+ * argument. The RESEND (`VerificationResend`) is a Server Action, because it
29
+ * needs nothing but an address.
34
30
  *
35
31
  * Which screen an outcome produces is decided in
36
32
  * `lib/account/register-state.ts`; this component renders it.
@@ -59,39 +55,13 @@ const PROMPTS: Readonly<
59
55
  },
60
56
  };
61
57
 
62
- /**
63
- * What a SUBMITTED resend says. `idle` has no entry because it is the
64
- * pre-submit state and renders nothing.
65
- *
66
- * `sent` is worded so it is true for every address a shopper can type — one
67
- * with a pending account, one with none, and one the platform's throttle
68
- * silently swallowed. That is the same neutrality the reset-request form
69
- * carries, and `verificationResendState` is where it is decided.
70
- */
71
- const RESEND_MESSAGES: Readonly<
72
- Record<'sent' | 'failed', { tone: AccountMessageTone; title: string; body: string }>
73
- > = {
74
- sent: {
75
- tone: 'success',
76
- title: 'On its way',
77
- 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.',
78
- },
79
- failed: {
80
- tone: 'error',
81
- title: 'We could not send that e-mail',
82
- body: 'Something went wrong on our side and nothing has been sent. Please try again in a few minutes.',
83
- },
84
- };
85
-
86
58
  export function RegisterForm({ locale }: { locale: LocaleBinding }) {
87
59
  const [firstName, setFirstName] = useState('');
88
60
  const [lastName, setLastName] = useState('');
89
61
  const [emailAddress, setEmailAddress] = useState('');
90
62
  const [password, setPassword] = useState('');
91
63
  const [state, setState] = useState<RegisterState>('form');
92
- const [resend, setResend] = useState<VerificationResendState>('idle');
93
64
  const [pending, setPending] = useState(false);
94
- const [resendPending, setResendPending] = useState(false);
95
65
 
96
66
  /**
97
67
  * The guard is the FIRST half of #1471's double-submit fix: the button is
@@ -118,19 +88,6 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
118
88
  setState(registerState(outcome));
119
89
  }
120
90
 
121
- /**
122
- * Same guard, and it matters more here: this button triggers a MAIL, so a
123
- * re-entrant click is a mail a shopper did not ask for. The platform throttles
124
- * it too — silently, which is why a refused resend still renders as sent.
125
- */
126
- async function requestAnotherMail(): Promise<void> {
127
- if (resendPending) return;
128
- setResendPending(true);
129
- const next = await submitVerificationRequest(emailAddress);
130
- setResendPending(false);
131
- setResend(next);
132
- }
133
-
134
91
  if (state === 'signed-in-already') {
135
92
  return (
136
93
  <div className="space-y-6" data-fc-account-mode="register" data-fc-account-state={state}>
@@ -138,8 +95,8 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
138
95
  This address is already confirmed, so there is nothing left to do here — you can sign in
139
96
  with it whenever you like.
140
97
  </AccountMessage>
141
- <LocaleLink href="/" locale={locale} className={buttonVariants()}>
142
- Continue shopping
98
+ <LocaleLink href="/account" locale={locale} className={buttonVariants()}>
99
+ Sign in
143
100
  </LocaleLink>
144
101
  </div>
145
102
  );
@@ -147,27 +104,17 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
147
104
 
148
105
  if (state === 'verify-your-email' || state === 'already-registered') {
149
106
  const prompt = PROMPTS[state];
150
- const resendMessage = resend === 'idle' ? null : RESEND_MESSAGES[resend];
151
107
  return (
152
108
  <div className="space-y-6" data-fc-account-mode="register" data-fc-account-state={state}>
153
109
  <AccountMessage tone={prompt.tone} title={prompt.title}>
154
110
  {prompt.body}
155
111
  </AccountMessage>
156
112
 
157
- {resendMessage && (
158
- <AccountMessage tone={resendMessage.tone} title={resendMessage.title}>
159
- {resendMessage.body}
160
- </AccountMessage>
161
- )}
162
-
163
- <div className="flex flex-wrap gap-3">
164
- <Button type="button" disabled={resendPending} onClick={() => void requestAnotherMail()}>
165
- {resendPending ? 'Sending…' : 'Send the confirmation e-mail again'}
166
- </Button>
113
+ <VerificationResend emailAddress={emailAddress}>
167
114
  <LocaleLink href="/" locale={locale} className={buttonVariants({ variant: 'ghost' })}>
168
115
  Continue shopping
169
116
  </LocaleLink>
170
- </div>
117
+ </VerificationResend>
171
118
  </div>
172
119
  );
173
120
  }
@@ -252,6 +199,17 @@ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
252
199
  <Button type="submit" disabled={pending}>
253
200
  {pending ? 'Creating your account…' : 'Create account'}
254
201
  </Button>
202
+
203
+ <p className="text-sm text-muted-foreground">
204
+ Already have an account?{' '}
205
+ <LocaleLink
206
+ href="/account"
207
+ locale={locale}
208
+ className="font-medium text-primary underline-offset-4 hover:underline"
209
+ >
210
+ Sign in
211
+ </LocaleLink>
212
+ </p>
255
213
  </form>
256
214
  );
257
215
  }
@@ -80,8 +80,8 @@ export function ResetPasswordForm({ token, locale }: { token: string; locale: Lo
80
80
  <AccountMessage tone="success" title="Your password has been changed">
81
81
  You can sign in with your new password from now on.
82
82
  </AccountMessage>
83
- <LocaleLink href="/" locale={locale} className={buttonVariants()}>
84
- Continue shopping
83
+ <LocaleLink href="/account" locale={locale} className={buttonVariants()}>
84
+ Sign in
85
85
  </LocaleLink>
86
86
  </div>
87
87
  );