@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.
- package/package.json +1 -1
- package/templates/storefront/README.md +52 -5
- package/templates/storefront/src/app/account/page.tsx +51 -0
- package/templates/storefront/src/app/layout.tsx +4 -5
- package/templates/storefront/src/app/verify/page.tsx +16 -4
- package/templates/storefront/src/components/Header.tsx +13 -1
- package/templates/storefront/src/components/account/AccountPanel.tsx +289 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +15 -66
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +2 -2
- package/templates/storefront/src/components/account/VerificationResend.tsx +96 -0
- package/templates/storefront/src/lib/account/account-state.ts +152 -0
- package/templates/storefront/src/lib/account-session.ts +83 -16
- package/templates/storefront/src/lib/error-messages.ts +102 -8
- package/templates/storefront/src/lib/seo/noindex.ts +5 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +2 -0
- package/templates/storefront/src/lib/shop-session.ts +71 -18
- package/templates/storefront-shadcn/README.md +52 -5
- package/templates/storefront-shadcn/src/app/account/page.tsx +51 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +4 -5
- package/templates/storefront-shadcn/src/app/verify/page.tsx +16 -4
- package/templates/storefront-shadcn/src/components/Header.tsx +17 -1
- package/templates/storefront-shadcn/src/components/account/AccountPanel.tsx +290 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +19 -61
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +2 -2
- package/templates/storefront-shadcn/src/components/account/VerificationResend.tsx +92 -0
- package/templates/storefront-shadcn/src/lib/account/account-state.ts +152 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +83 -16
- package/templates/storefront-shadcn/src/lib/error-messages.ts +102 -8
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +5 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +2 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +71 -18
|
@@ -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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
</
|
|
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
|
-
|
|
83
|
+
<LocaleLink href="/account" locale={locale} className={buttonVariants()}>
|
|
84
|
+
Sign in
|
|
85
85
|
</LocaleLink>
|
|
86
86
|
</div>
|
|
87
87
|
);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { AccountMessage, type AccountMessageTone } from './AccountMessage';
|
|
6
|
+
import { Button } from '../ui/button';
|
|
7
|
+
import { submitVerificationRequest } from '../../lib/account-actions';
|
|
8
|
+
import type { VerificationResendState } from '../../lib/account/register-state';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Send the confirmation e-mail again (#1471, #1093) — the one key an UNCONFIRMED
|
|
12
|
+
* account's owner has. Rendered wherever such an account stops a shopper: the
|
|
13
|
+
* register prompt, for the address they just registered, and the sign-in form,
|
|
14
|
+
* after the API refused a sign-in NOT_VERIFIED. Without it that shopper has
|
|
15
|
+
* nowhere to go: the mail they lost is the only way to confirm the account.
|
|
16
|
+
*
|
|
17
|
+
* A Server Action rather than the shopper's socket: the resend needs nothing but
|
|
18
|
+
* an address, so it runs on the anonymous channel singleton like the reset
|
|
19
|
+
* request (`lib/account-actions.ts`).
|
|
20
|
+
*
|
|
21
|
+
* Renders the resend's answer, then a row holding the button and whatever the
|
|
22
|
+
* caller passes as `children` — its own next step beside it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a SUBMITTED resend says. `idle` has no entry because it is the
|
|
27
|
+
* pre-submit state and renders nothing.
|
|
28
|
+
*
|
|
29
|
+
* `sent` is worded so it is true for every address a shopper can type — one
|
|
30
|
+
* with a pending account, one with none, and one the platform's throttle
|
|
31
|
+
* silently swallowed. That is the same neutrality the reset-request form
|
|
32
|
+
* carries, and `verificationResendState` is where it is decided.
|
|
33
|
+
*/
|
|
34
|
+
const RESEND_MESSAGES: Readonly<
|
|
35
|
+
Record<'sent' | 'failed', { tone: AccountMessageTone; title: string; body: string }>
|
|
36
|
+
> = {
|
|
37
|
+
sent: {
|
|
38
|
+
tone: 'success',
|
|
39
|
+
title: 'On its way',
|
|
40
|
+
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.',
|
|
41
|
+
},
|
|
42
|
+
failed: {
|
|
43
|
+
tone: 'error',
|
|
44
|
+
title: 'We could not send that e-mail',
|
|
45
|
+
body: 'Something went wrong on our side and nothing has been sent. Please try again in a few minutes.',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function VerificationResend({
|
|
50
|
+
emailAddress,
|
|
51
|
+
children,
|
|
52
|
+
}: {
|
|
53
|
+
/** The address the confirmation mail goes to. */
|
|
54
|
+
emailAddress: string;
|
|
55
|
+
/** The caller's own next step, rendered beside the button. */
|
|
56
|
+
children?: ReactNode;
|
|
57
|
+
}) {
|
|
58
|
+
const [resend, setResend] = useState<VerificationResendState>('idle');
|
|
59
|
+
const [pending, setPending] = useState(false);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* This button triggers a MAIL, so a re-entrant click is a mail a shopper did
|
|
63
|
+
* not ask for: `pending` refuses one even before `disabled` has painted. The
|
|
64
|
+
* platform throttles it too — silently, which is why a refused resend still
|
|
65
|
+
* renders as sent.
|
|
66
|
+
*/
|
|
67
|
+
async function requestAnotherMail(): Promise<void> {
|
|
68
|
+
if (pending) return;
|
|
69
|
+
setPending(true);
|
|
70
|
+
const next = await submitVerificationRequest(emailAddress);
|
|
71
|
+
setPending(false);
|
|
72
|
+
setResend(next);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const message = resend === 'idle' ? null : RESEND_MESSAGES[resend];
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
{message && (
|
|
79
|
+
<AccountMessage tone={message.tone} title={message.title}>
|
|
80
|
+
{message.body}
|
|
81
|
+
</AccountMessage>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<div className="flex flex-wrap gap-3">
|
|
85
|
+
<Button type="button" disabled={pending} onClick={() => void requestAnotherMail()}>
|
|
86
|
+
{pending ? 'Sending…' : 'Send the confirmation e-mail again'}
|
|
87
|
+
</Button>
|
|
88
|
+
{children}
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
@@ -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
|
+
}
|