@forgecart/cli 2.202610141948.0 → 2.202610142335.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront/README.md +52 -5
  3. package/templates/storefront/src/app/account/page.tsx +51 -0
  4. package/templates/storefront/src/app/layout.tsx +4 -5
  5. package/templates/storefront/src/app/verify/page.tsx +16 -4
  6. package/templates/storefront/src/components/Header.tsx +13 -1
  7. package/templates/storefront/src/components/account/AccountPanel.tsx +289 -0
  8. package/templates/storefront/src/components/account/RegisterForm.tsx +15 -66
  9. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +2 -2
  10. package/templates/storefront/src/components/account/VerificationResend.tsx +96 -0
  11. package/templates/storefront/src/lib/account/account-state.ts +152 -0
  12. package/templates/storefront/src/lib/account-session.ts +83 -16
  13. package/templates/storefront/src/lib/error-messages.ts +102 -8
  14. package/templates/storefront/src/lib/seo/noindex.ts +5 -0
  15. package/templates/storefront/src/lib/seo/sitemap-entries.ts +2 -0
  16. package/templates/storefront/src/lib/shop-session.ts +71 -18
  17. package/templates/storefront-shadcn/README.md +52 -5
  18. package/templates/storefront-shadcn/src/app/account/page.tsx +51 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +4 -5
  20. package/templates/storefront-shadcn/src/app/verify/page.tsx +16 -4
  21. package/templates/storefront-shadcn/src/components/Header.tsx +17 -1
  22. package/templates/storefront-shadcn/src/components/account/AccountPanel.tsx +290 -0
  23. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +19 -61
  24. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +2 -2
  25. package/templates/storefront-shadcn/src/components/account/VerificationResend.tsx +92 -0
  26. package/templates/storefront-shadcn/src/lib/account/account-state.ts +152 -0
  27. package/templates/storefront-shadcn/src/lib/account-session.ts +83 -16
  28. package/templates/storefront-shadcn/src/lib/error-messages.ts +102 -8
  29. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +5 -0
  30. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +2 -0
  31. package/templates/storefront-shadcn/src/lib/shop-session.ts +71 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/cli",
3
- "version": "2.202610141948.0",
3
+ "version": "2.202610142335.0",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and operating ForgeCart channel storefronts",
6
6
  "bin": {
@@ -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, not because anything in this storefront navigates there:
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
- account-actions.ts # password-reset Server Actions
182
- account/ # pure account-link rules (token, states, ErrorCode map)
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 with live cart count
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
- AccountMessage.tsx # the one outcome block both account pages use
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 only affordance for `/register` (#1471). It sits in the
140
- footer rather than the header nav on purpose: the template
141
- ships no sign-in surface yet, and an account entry in the
142
- primary nav would advertise an account AREA that does not
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
- /** 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
+ }