@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
package/package.json
CHANGED
|
@@ -140,10 +140,49 @@ defers its first flush so a fast add-to-cart wins the first-touch race. The
|
|
|
140
140
|
mirror is what the browser's shop socket boots its auth from: written without
|
|
141
141
|
it, a relay-established session would never reach the client at all.
|
|
142
142
|
|
|
143
|
+
## Customer account
|
|
144
|
+
|
|
145
|
+
The scaffold ships the shopper's account surface, so a store starts with
|
|
146
|
+
sign-in, sign-out and registration working — extend it rather than rebuild it:
|
|
147
|
+
|
|
148
|
+
| Route | What it does |
|
|
149
|
+
| ----------- | ------------------------------------------------------------ |
|
|
150
|
+
| `/account` | signs a shopper in, greets them by name, signs them out |
|
|
151
|
+
| `/register` | creates an account; its confirmation mail links to `/verify` |
|
|
152
|
+
|
|
153
|
+
The header links `/account`, the footer `/register`. Five rules keep it
|
|
154
|
+
working — each one was learned from a store that broke it:
|
|
155
|
+
|
|
156
|
+
- **Account pages are static shells.**
|
|
157
|
+
[`src/app/account/page.tsx`](src/app/account/page.tsx) reads no session on
|
|
158
|
+
the server — no `next/headers`, no `connection()`, no session module. The
|
|
159
|
+
session lives on the shopper's own socket, in the `AccountPanel` client
|
|
160
|
+
island, which waits for the `CartProvider` to arm it (`useCart().hydrated`).
|
|
161
|
+
A session read on the server path throws during the render and 500s the page.
|
|
162
|
+
- **Sign in and out only through the session helpers.** `loginCustomer` and
|
|
163
|
+
`logoutCustomer` ([`src/lib/account-session.ts`](src/lib/account-session.ts))
|
|
164
|
+
replace the socket with one carrying the new session (or none) and write both
|
|
165
|
+
session cookies before they resolve: a socket left on the session it opened
|
|
166
|
+
with refuses the very next call. `findSignedInCustomer` says who is signed in.
|
|
167
|
+
- **Every refusal renders through `shopperMessage`**
|
|
168
|
+
([`src/lib/error-messages.ts`](src/lib/error-messages.ts)): it has copy for
|
|
169
|
+
every account refusal the shop API answers, and appends the raw code to its
|
|
170
|
+
generic line for any code it does not know. A new refusal's copy goes there,
|
|
171
|
+
once.
|
|
172
|
+
- **Registration requires the password.** The API treats it as optional, and an
|
|
173
|
+
account registered without one can never sign in.
|
|
174
|
+
- **`lib/shop-config` is server-only**, so no client component imports it, and
|
|
175
|
+
**the header links only routes the template ships**.
|
|
176
|
+
|
|
177
|
+
A store scaffolded before the surface shipped adopts it with `forgecart
|
|
178
|
+
refresh`: the account files it never had are rendered, the files it never
|
|
179
|
+
edited are replaced, and a file it edited — its `Header.tsx`, say — is reported
|
|
180
|
+
as a conflict and left as it is.
|
|
181
|
+
|
|
143
182
|
## Account links
|
|
144
183
|
|
|
145
184
|
Two routes exist because the PLATFORM links to them from the e-mails it sends
|
|
146
|
-
a shopper
|
|
185
|
+
a shopper:
|
|
147
186
|
|
|
148
187
|
| Route | Mail | What it does |
|
|
149
188
|
| ---------------------------- | ------------------- | ------------------------------------------------------------ |
|
|
@@ -178,10 +217,13 @@ src/
|
|
|
178
217
|
cart-actions.ts # cart Server Actions (incl. coupons)
|
|
179
218
|
checkout-actions.ts # checkout Server Actions
|
|
180
219
|
cart-context.tsx # client cart state + typed error channel
|
|
181
|
-
|
|
182
|
-
account
|
|
220
|
+
shop-session.ts # the shopper's own socket + session cookie sync
|
|
221
|
+
account-session.ts # register, sign-in, sign-out on that socket
|
|
222
|
+
account-actions.ts # password-reset + verification-resend Server Actions
|
|
223
|
+
account/ # pure account rules (token, states, ErrorCode map)
|
|
224
|
+
error-messages.ts # shopper copy for every typed error code
|
|
183
225
|
components/
|
|
184
|
-
Header.tsx # top nav
|
|
226
|
+
Header.tsx # top nav (Home / Products / Cart / Account) + cart count
|
|
185
227
|
ProductCard.tsx # product tile
|
|
186
228
|
ProductPurchase.tsx # variant picker + purchase options + add-to-cart
|
|
187
229
|
CartView.tsx # cart lines, coupons, totals, checkout CTA
|
|
@@ -191,7 +233,10 @@ src/
|
|
|
191
233
|
RatesStep.tsx # grouped radio-card rate selection
|
|
192
234
|
PaymentFormEmbed.tsx # origin-checked hosted-form host
|
|
193
235
|
account/
|
|
194
|
-
|
|
236
|
+
AccountPanel.tsx # sign-in form, greeting, sign-out (/account)
|
|
237
|
+
RegisterForm.tsx # create an account (/register)
|
|
238
|
+
VerificationResend.tsx # send the confirmation mail again
|
|
239
|
+
AccountMessage.tsx # the one outcome block the account pages use
|
|
195
240
|
ResetPasswordForm.tsx # set a new password from a reset token
|
|
196
241
|
RequestPasswordResetForm.tsx # ask for a reset link
|
|
197
242
|
app/
|
|
@@ -202,6 +247,8 @@ src/
|
|
|
202
247
|
products/ # grid + [slug] detail
|
|
203
248
|
cart/page.tsx # cart
|
|
204
249
|
checkout/page.tsx # the stepped checkout
|
|
250
|
+
account/page.tsx # sign-in + greeting: a static shell around AccountPanel
|
|
251
|
+
register/page.tsx # create an account
|
|
205
252
|
verify/page.tsx # e-mail verification link target
|
|
206
253
|
reset-password/page.tsx # password-reset link target + request form
|
|
207
254
|
```
|
|
@@ -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
|
+
}
|
|
@@ -136,11 +136,10 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|
|
136
136
|
<LocaleLink href="/cart" locale={binding} className="link link-hover">
|
|
137
137
|
Cart
|
|
138
138
|
</LocaleLink>
|
|
139
|
-
{/* The
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
exist. A merchant who adds sign-in moves it up. */}
|
|
139
|
+
{/* The direct way to `/register` (#1471). The header's Account
|
|
140
|
+
link (#1093) is the account entry — sign-in, with
|
|
141
|
+
registration one link away — and this is the shortcut for
|
|
142
|
+
a shopper who already knows they are new. */}
|
|
144
143
|
<LocaleLink href="/register" locale={binding} className="link link-hover">
|
|
145
144
|
Create account
|
|
146
145
|
</LocaleLink>
|
|
@@ -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
|
-
/**
|
|
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<
|
|
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: '
|
|
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=
|
|
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
|
-
/**
|
|
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
|
+
}
|