@forgecart/cli 2.202608300703.0 → 2.202609190800.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 (76) hide show
  1. package/dist/src/commands/init.d.ts +21 -2
  2. package/dist/src/commands/init.js +18 -2
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +42 -4
  6. package/templates/storefront/next.config.js +29 -7
  7. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  8. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  9. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  10. package/templates/storefront/src/app/cart/page.tsx +14 -2
  11. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  12. package/templates/storefront/src/app/layout.tsx +85 -22
  13. package/templates/storefront/src/app/page.tsx +63 -20
  14. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  15. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  16. package/templates/storefront/src/app/ping/route.ts +1 -1
  17. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  18. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  19. package/templates/storefront/src/app/products/page.tsx +41 -6
  20. package/templates/storefront/src/app/register/page.tsx +54 -0
  21. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  22. package/templates/storefront/src/app/robots.ts +69 -0
  23. package/templates/storefront/src/app/sitemap.ts +106 -0
  24. package/templates/storefront/src/app/verify/page.tsx +155 -0
  25. package/templates/storefront/src/components/CartView.tsx +26 -7
  26. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  27. package/templates/storefront/src/components/Header.tsx +30 -10
  28. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  29. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  30. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  31. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  32. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  33. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  34. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  35. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  36. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  37. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  38. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  39. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  40. package/templates/storefront/src/lib/account-actions.ts +76 -0
  41. package/templates/storefront/src/lib/account-session.ts +47 -0
  42. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  43. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  44. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  45. package/templates/storefront/src/lib/experiments.ts +1 -1
  46. package/templates/storefront/src/lib/forgecart.ts +300 -27
  47. package/templates/storefront/src/lib/format.ts +12 -14
  48. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  49. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  50. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  51. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  52. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  53. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  54. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  55. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  56. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  57. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  58. package/templates/storefront/src/lib/money.ts +50 -0
  59. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  60. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  61. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  62. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  63. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  64. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  65. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  66. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  67. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  68. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  69. package/templates/storefront/src/lib/session-actions.ts +15 -8
  70. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  71. package/templates/storefront/src/lib/shop-config.ts +9 -2
  72. package/templates/storefront/src/lib/shop-session.ts +20 -1
  73. package/templates/storefront/src/lib/track-forward.ts +43 -14
  74. package/templates/storefront/src/middleware.ts +150 -8
  75. package/templates/storefront/src/seo/redirects.ts +44 -0
  76. package/templates/storefront/src/server/runner.ts +1 -2
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The one block every account-link state renders through (#1472).
5
+ *
6
+ * `/verify` and `/reset-password` are pages whose whole content IS an outcome:
7
+ * a shopper arrives holding a one-shot token and leaves knowing what happened
8
+ * to it. Routing all of those through one component is what keeps the
9
+ * vocabulary consistent — the same shape says "verified" and "this link has
10
+ * expired" — and keeps each page a table of copy rather than a pile of markup
11
+ * variants.
12
+ *
13
+ * Semantic daisyUI tokens only (`alert-success`, `alert-warning`, …), per the
14
+ * theme contract in `globals.css`: a brand lives in that block alone, so no
15
+ * component here names a color.
16
+ *
17
+ * No `'use client'` and no hooks, so it renders on the server for the verify
18
+ * page and rides into the bundle when a client form renders it.
19
+ */
20
+
21
+ export type AccountMessageTone = 'success' | 'info' | 'warning' | 'error';
22
+
23
+ /**
24
+ * The class AND the live-region role per tone.
25
+ *
26
+ * `alert` is for something that has gone wrong and interrupts; `status` is a
27
+ * polite announcement. A success message that arrives with the first paint is
28
+ * not an interruption, and announcing it as one is how a screen reader ends up
29
+ * shouting "Your e-mail is verified".
30
+ */
31
+ const TONES: Readonly<Record<AccountMessageTone, { className: string; role: 'alert' | 'status' }>> =
32
+ {
33
+ success: { className: 'alert-success', role: 'status' },
34
+ info: { className: 'alert-info', role: 'status' },
35
+ warning: { className: 'alert-warning', role: 'alert' },
36
+ error: { className: 'alert-error', role: 'alert' },
37
+ };
38
+
39
+ export function AccountMessage({
40
+ tone,
41
+ title,
42
+ children,
43
+ }: {
44
+ tone: AccountMessageTone;
45
+ /** The outcome in one line — what happened, not what to do about it. */
46
+ title: string;
47
+ /** What the shopper can do next. */
48
+ children: ReactNode;
49
+ }): ReactNode {
50
+ const { className, role } = TONES[tone];
51
+ return (
52
+ <div role={role} className={`alert ${className} items-start`}>
53
+ <div className="space-y-1">
54
+ <p className="font-semibold">{title}</p>
55
+ <p className="text-sm opacity-90">{children}</p>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,283 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ import { AccountMessage, type AccountMessageTone } from './AccountMessage';
6
+ import { LocaleLink } from '../LocaleLink';
7
+ import { submitVerificationRequest } from '../../lib/account-actions';
8
+ import { registerCustomerAccount } from '../../lib/account-session';
9
+ import {
10
+ registerState,
11
+ type RegisterState,
12
+ type VerificationResendState,
13
+ } from '../../lib/account/register-state';
14
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
15
+
16
+ /**
17
+ * Create an account (#1471).
18
+ *
19
+ * Registering NEVER signs a shopper in. The shop API mints the account
20
+ * unverified and mails a confirmation link, so the screen after a successful
21
+ * submit is a prompt pointing at an inbox — not a redirect into an account
22
+ * area, and never a `login` call: a storefront that made one was refused
23
+ * NOT_VERIFIED 287 ms later (prod, 2026-09-06), which is the defect this page
24
+ * exists to stop rendering as a failure.
25
+ *
26
+ * The submit runs on the shopper's OWN websocket rather than through a Server
27
+ * Action, because the API's protection against a double-clicked submit is
28
+ * scoped to their session — `lib/account-session.ts` carries the full
29
+ * argument. The RESEND below is a Server Action, because it needs nothing but
30
+ * an address.
31
+ *
32
+ * Which screen an outcome produces is decided in
33
+ * `lib/account/register-state.ts`; this component renders it.
34
+ */
35
+
36
+ /**
37
+ * The two states where the account EXISTS and the shopper's next move is their
38
+ * inbox. They differ only in whose account it is, which is the one thing the
39
+ * copy has to get right — the resend affordance is the same either way.
40
+ */
41
+ const PROMPTS: Readonly<
42
+ Record<
43
+ 'verify-your-email' | 'already-registered',
44
+ { tone: AccountMessageTone; title: string; body: string }
45
+ >
46
+ > = {
47
+ 'verify-your-email': {
48
+ tone: 'info',
49
+ title: 'Check your e-mail',
50
+ body: 'Your account is ready. Open the link we just sent to confirm this address — you can sign in once it is confirmed.',
51
+ },
52
+ 'already-registered': {
53
+ tone: 'warning',
54
+ title: 'That address already has an account',
55
+ body: 'If it is yours and you never confirmed it, have the confirmation mail sent again. If you did confirm it, sign in with that address instead.',
56
+ },
57
+ };
58
+
59
+ /**
60
+ * What a SUBMITTED resend says. `idle` has no entry because it is the
61
+ * pre-submit state and renders nothing.
62
+ *
63
+ * `sent` is worded so it is true for every address a shopper can type — one
64
+ * with a pending account, one with none, and one the platform's throttle
65
+ * silently swallowed. That is the same neutrality the reset-request form
66
+ * carries, and `verificationResendState` is where it is decided.
67
+ */
68
+ const RESEND_MESSAGES: Readonly<
69
+ Record<'sent' | 'failed', { tone: AccountMessageTone; title: string; body: string }>
70
+ > = {
71
+ sent: {
72
+ tone: 'success',
73
+ title: 'On its way',
74
+ body: 'If that address is waiting to be confirmed, a link is on its way to it. A mail we sent a moment ago still works, so it is worth checking for that one too.',
75
+ },
76
+ failed: {
77
+ tone: 'error',
78
+ title: 'We could not send that e-mail',
79
+ body: 'Something went wrong on our side and nothing has been sent. Please try again in a few minutes.',
80
+ },
81
+ };
82
+
83
+ export function RegisterForm({ locale }: { locale: LocaleBinding }) {
84
+ const [firstName, setFirstName] = useState('');
85
+ const [lastName, setLastName] = useState('');
86
+ const [emailAddress, setEmailAddress] = useState('');
87
+ const [password, setPassword] = useState('');
88
+ const [state, setState] = useState<RegisterState>('form');
89
+ const [resend, setResend] = useState<VerificationResendState>('idle');
90
+ const [pending, setPending] = useState(false);
91
+ const [resendPending, setResendPending] = useState(false);
92
+
93
+ /**
94
+ * The guard is the FIRST half of #1471's double-submit fix: the button is
95
+ * disabled for the whole round trip, and `pending` refuses a re-entrant call
96
+ * as well — a keyboard Enter can fire while a click is already in flight,
97
+ * before `disabled` has painted. The SECOND half is the shop API's
98
+ * session-scoped idempotency, which covers everything a client cannot: a
99
+ * second tab, a refresh-and-resubmit, a client running no JavaScript at all.
100
+ * Neither half is sufficient alone, which is why both exist.
101
+ */
102
+ async function submit(): Promise<void> {
103
+ if (pending) return;
104
+ setPending(true);
105
+ const outcome = await registerCustomerAccount({
106
+ firstName,
107
+ lastName,
108
+ emailAddress,
109
+ password,
110
+ });
111
+ setPending(false);
112
+ // The password only ever lived in this component's state; drop it as soon
113
+ // as the answer is in, whatever the answer was.
114
+ setPassword('');
115
+ setState(registerState(outcome));
116
+ }
117
+
118
+ /**
119
+ * Same guard, and it matters more here: this button triggers a MAIL, so a
120
+ * re-entrant click is a mail a shopper did not ask for. The platform throttles
121
+ * it too — silently, which is why a refused resend still renders as sent.
122
+ */
123
+ async function requestAnotherMail(): Promise<void> {
124
+ if (resendPending) return;
125
+ setResendPending(true);
126
+ const next = await submitVerificationRequest(emailAddress);
127
+ setResendPending(false);
128
+ setResend(next);
129
+ }
130
+
131
+ if (state === 'signed-in-already') {
132
+ return (
133
+ <div className="space-y-6" data-fc-account-mode="register" data-fc-account-state={state}>
134
+ <AccountMessage tone="success" title="Your account is ready">
135
+ This address is already confirmed, so there is nothing left to do here — you can sign in
136
+ with it whenever you like.
137
+ </AccountMessage>
138
+ <LocaleLink href="/" locale={locale} className="btn btn-primary">
139
+ Continue shopping
140
+ </LocaleLink>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ if (state === 'verify-your-email' || state === 'already-registered') {
146
+ const prompt = PROMPTS[state];
147
+ const resendMessage = resend === 'idle' ? null : RESEND_MESSAGES[resend];
148
+ return (
149
+ <div className="space-y-6" data-fc-account-mode="register" data-fc-account-state={state}>
150
+ <AccountMessage tone={prompt.tone} title={prompt.title}>
151
+ {prompt.body}
152
+ </AccountMessage>
153
+
154
+ {resendMessage && (
155
+ <AccountMessage tone={resendMessage.tone} title={resendMessage.title}>
156
+ {resendMessage.body}
157
+ </AccountMessage>
158
+ )}
159
+
160
+ <div className="flex flex-wrap gap-3">
161
+ <button
162
+ type="button"
163
+ className="btn btn-primary"
164
+ disabled={resendPending}
165
+ onClick={() => void requestAnotherMail()}
166
+ >
167
+ {resendPending ? 'Sending…' : 'Send the confirmation e-mail again'}
168
+ </button>
169
+ <LocaleLink href="/" locale={locale} className="btn btn-ghost">
170
+ Continue shopping
171
+ </LocaleLink>
172
+ </div>
173
+ </div>
174
+ );
175
+ }
176
+
177
+ return (
178
+ <form
179
+ className="space-y-4"
180
+ data-fc-account-mode="register"
181
+ data-fc-account-state={state}
182
+ onSubmit={(event) => {
183
+ event.preventDefault();
184
+ void submit();
185
+ }}
186
+ >
187
+ <p className="text-sm text-base-content/60">
188
+ Keep your details for next time. We will send a link to confirm your address — the account
189
+ works once you have opened it.
190
+ </p>
191
+
192
+ {state === 'failed' && (
193
+ <AccountMessage tone="error" title="We could not create your account">
194
+ Something went wrong on our side and no account has been created. Please try again in a
195
+ few minutes.
196
+ </AccountMessage>
197
+ )}
198
+
199
+ <div className="grid gap-4 sm:grid-cols-2">
200
+ <div>
201
+ <label
202
+ className="mb-1 block text-sm font-medium text-base-content/70"
203
+ htmlFor="register-first-name"
204
+ >
205
+ First name
206
+ </label>
207
+ <input
208
+ id="register-first-name"
209
+ type="text"
210
+ required
211
+ autoComplete="given-name"
212
+ value={firstName}
213
+ onChange={(event) => setFirstName(event.target.value)}
214
+ className="input w-full"
215
+ />
216
+ </div>
217
+
218
+ <div>
219
+ <label
220
+ className="mb-1 block text-sm font-medium text-base-content/70"
221
+ htmlFor="register-last-name"
222
+ >
223
+ Last name
224
+ </label>
225
+ <input
226
+ id="register-last-name"
227
+ type="text"
228
+ required
229
+ autoComplete="family-name"
230
+ value={lastName}
231
+ onChange={(event) => setLastName(event.target.value)}
232
+ className="input w-full"
233
+ />
234
+ </div>
235
+ </div>
236
+
237
+ <div>
238
+ <label
239
+ className="mb-1 block text-sm font-medium text-base-content/70"
240
+ htmlFor="register-email"
241
+ >
242
+ E-mail address
243
+ </label>
244
+ <input
245
+ id="register-email"
246
+ type="email"
247
+ required
248
+ autoComplete="email"
249
+ value={emailAddress}
250
+ onChange={(event) => setEmailAddress(event.target.value)}
251
+ className="input w-full"
252
+ />
253
+ </div>
254
+
255
+ <div>
256
+ <label
257
+ className="mb-1 block text-sm font-medium text-base-content/70"
258
+ htmlFor="register-password"
259
+ >
260
+ Password
261
+ </label>
262
+ <input
263
+ id="register-password"
264
+ type="password"
265
+ required
266
+ autoComplete="new-password"
267
+ value={password}
268
+ onChange={(event) => setPassword(event.target.value)}
269
+ className="input w-full"
270
+ />
271
+ {/* No confirmation field, and no password rule of our own: whether a
272
+ password is acceptable belongs to the shop API. The reset form
273
+ carries a confirmation because a typo there SPENDS a one-shot
274
+ token; here a shopper who mistypes simply submits again, so a
275
+ second field would buy them nothing but typing. */}
276
+ </div>
277
+
278
+ <button type="submit" className="btn btn-primary" disabled={pending}>
279
+ {pending ? 'Creating your account…' : 'Create account'}
280
+ </button>
281
+ </form>
282
+ );
283
+ }
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ import { AccountMessage } from './AccountMessage';
6
+ import { LocaleLink } from '../LocaleLink';
7
+ import { submitPasswordResetRequest } from '../../lib/account-actions';
8
+ import type { PasswordResetRequestState } from '../../lib/account/reset-password-state';
9
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
10
+
11
+ /**
12
+ * Ask for a password-reset link (#1472).
13
+ *
14
+ * The answer is the SAME whether or not an account exists for the address —
15
+ * that is the whole design of this form. A page that distinguished the two
16
+ * would be an account-enumeration oracle: type an address, read the screen,
17
+ * learn whether that person shops here. The shop API is built the same way
18
+ * (it reports a match to nobody but the mailbox), and
19
+ * `lib/account/reset-password-state.ts` is where the two successful answers
20
+ * are collapsed into one.
21
+ *
22
+ * `failed` is NOT that case: it means the request never reached the API, which
23
+ * says nothing about any account and would otherwise be a sent-mail promise we
24
+ * know to be false.
25
+ */
26
+ export function RequestPasswordResetForm({ locale }: { locale: LocaleBinding }) {
27
+ const [emailAddress, setEmailAddress] = useState('');
28
+ const [state, setState] = useState<PasswordResetRequestState>('form');
29
+ const [pending, setPending] = useState(false);
30
+
31
+ async function submit(): Promise<void> {
32
+ setPending(true);
33
+ const next = await submitPasswordResetRequest(emailAddress);
34
+ setPending(false);
35
+ setState(next);
36
+ }
37
+
38
+ if (state === 'sent') {
39
+ return (
40
+ <div className="space-y-6" data-fc-account-mode="request" data-fc-account-state={state}>
41
+ <AccountMessage tone="info" title="Check your e-mail">
42
+ If an account exists for that address, we have sent it a link to choose a new password.
43
+ The link works once, and only the newest one works.
44
+ </AccountMessage>
45
+ <LocaleLink href="/" locale={locale} className="btn btn-primary">
46
+ Continue shopping
47
+ </LocaleLink>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <form
54
+ className="space-y-4"
55
+ data-fc-account-mode="request"
56
+ data-fc-account-state={state}
57
+ onSubmit={(event) => {
58
+ event.preventDefault();
59
+ void submit();
60
+ }}
61
+ >
62
+ <p className="text-sm text-base-content/60">
63
+ Enter the e-mail address you shop with and we will send you a link to choose a new password.
64
+ </p>
65
+
66
+ {state === 'failed' && (
67
+ <AccountMessage tone="error" title="We could not send that e-mail">
68
+ Something went wrong on our side and nothing has been sent yet. Please try again in a few
69
+ minutes.
70
+ </AccountMessage>
71
+ )}
72
+
73
+ <div>
74
+ <label
75
+ className="mb-1 block text-sm font-medium text-base-content/70"
76
+ htmlFor="reset-email"
77
+ >
78
+ E-mail address
79
+ </label>
80
+ <input
81
+ id="reset-email"
82
+ type="email"
83
+ required
84
+ autoComplete="email"
85
+ value={emailAddress}
86
+ onChange={(event) => setEmailAddress(event.target.value)}
87
+ className="input w-full"
88
+ />
89
+ </div>
90
+
91
+ <button type="submit" className="btn btn-primary" disabled={pending}>
92
+ {pending ? 'Sending…' : 'Send reset link'}
93
+ </button>
94
+ </form>
95
+ );
96
+ }
@@ -0,0 +1,169 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ import { AccountMessage, type AccountMessageTone } from './AccountMessage';
6
+ import { LocaleLink } from '../LocaleLink';
7
+ import { submitPasswordReset } from '../../lib/account-actions';
8
+ import {
9
+ checkNewPassword,
10
+ type NewPasswordProblem,
11
+ type ResetPasswordState,
12
+ } from '../../lib/account/reset-password-state';
13
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
14
+
15
+ /**
16
+ * Set a new password from the token in a reset mail (#1472).
17
+ *
18
+ * The token is spent by the SUBMIT, not by the render — see the page. The
19
+ * confirmation field never leaves the browser: it exists so a shopper catches
20
+ * a typo before spending their one-shot token, and the shop API remains the
21
+ * only authority on whether a password is acceptable (it answers
22
+ * `PASSWORD_VALIDATION_FAILED`, which arrives as `password-rejected`).
23
+ *
24
+ * The submit goes through a Server Action rather than the shopper's own
25
+ * websocket, because a password reset is not a session operation — see
26
+ * `lib/account-actions.ts`. The action returns the STATE; this component only
27
+ * renders it.
28
+ */
29
+
30
+ /** The two refusals that keep the form up, with the copy for each. */
31
+ const FORM_ALERTS: Readonly<
32
+ Record<'password-rejected' | 'failed', { tone: AccountMessageTone; title: string; body: string }>
33
+ > = {
34
+ 'password-rejected': {
35
+ tone: 'warning',
36
+ title: 'Choose a different password',
37
+ body: 'That password does not meet this store’s requirements. A longer one, mixing letters, numbers and symbols, will be accepted.',
38
+ },
39
+ failed: {
40
+ tone: 'error',
41
+ title: 'We could not change your password',
42
+ body: 'Something went wrong on our side. Please try again in a few minutes — and if this link has stopped working by then, request a new one.',
43
+ },
44
+ };
45
+
46
+ /** Why the form will not submit yet. Never a reason the server gave. */
47
+ const PROBLEM_MESSAGES: Readonly<Record<NewPasswordProblem, string>> = {
48
+ empty: 'Enter a new password.',
49
+ mismatch: 'The two passwords do not match.',
50
+ };
51
+
52
+ export function ResetPasswordForm({ token, locale }: { token: string; locale: LocaleBinding }) {
53
+ const [password, setPassword] = useState('');
54
+ const [confirmation, setConfirmation] = useState('');
55
+ const [problem, setProblem] = useState<NewPasswordProblem | null>(null);
56
+ const [state, setState] = useState<ResetPasswordState>('form');
57
+ const [pending, setPending] = useState(false);
58
+
59
+ async function submit(): Promise<void> {
60
+ const found = checkNewPassword(password, confirmation);
61
+ setProblem(found);
62
+ if (found !== null) return;
63
+
64
+ setPending(true);
65
+ const next = await submitPasswordReset({ token, password });
66
+ setPending(false);
67
+ // The password only ever lived in this component's state; drop it as soon
68
+ // as the answer is in, whatever the answer was.
69
+ setPassword('');
70
+ setConfirmation('');
71
+ setState(next);
72
+ }
73
+
74
+ if (state === 'done') {
75
+ return (
76
+ <div className="space-y-6" data-fc-account-mode="reset" data-fc-account-state={state}>
77
+ <AccountMessage tone="success" title="Your password has been changed">
78
+ You can sign in with your new password from now on.
79
+ </AccountMessage>
80
+ <LocaleLink href="/" locale={locale} className="btn btn-primary">
81
+ Continue shopping
82
+ </LocaleLink>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ if (state === 'link-invalid') {
88
+ return (
89
+ <div className="space-y-6" data-fc-account-mode="reset" data-fc-account-state={state}>
90
+ <AccountMessage tone="warning" title="This reset link is no longer valid">
91
+ It has either been used already or it has expired. Ask for a new one and open the newest
92
+ e-mail — links from earlier requests stop working as soon as a newer one is sent.
93
+ </AccountMessage>
94
+ {/* Same route, no token: the request-a-link mode of this page. */}
95
+ <LocaleLink href="/reset-password" locale={locale} className="btn btn-primary">
96
+ Request a new link
97
+ </LocaleLink>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ const alert = state === 'form' ? null : FORM_ALERTS[state];
103
+
104
+ return (
105
+ <form
106
+ className="space-y-4"
107
+ data-fc-account-mode="reset"
108
+ data-fc-account-state={state}
109
+ onSubmit={(event) => {
110
+ event.preventDefault();
111
+ void submit();
112
+ }}
113
+ >
114
+ {alert && (
115
+ <AccountMessage tone={alert.tone} title={alert.title}>
116
+ {alert.body}
117
+ </AccountMessage>
118
+ )}
119
+
120
+ <div>
121
+ <label
122
+ className="mb-1 block text-sm font-medium text-base-content/70"
123
+ htmlFor="new-password"
124
+ >
125
+ New password
126
+ </label>
127
+ <input
128
+ id="new-password"
129
+ type="password"
130
+ required
131
+ autoComplete="new-password"
132
+ value={password}
133
+ onChange={(event) => setPassword(event.target.value)}
134
+ aria-invalid={problem !== null}
135
+ aria-describedby={problem === null ? undefined : 'new-password-problem'}
136
+ className={`input w-full ${problem === null ? '' : 'input-error'}`}
137
+ />
138
+ </div>
139
+
140
+ <div>
141
+ <label
142
+ className="mb-1 block text-sm font-medium text-base-content/70"
143
+ htmlFor="confirm-password"
144
+ >
145
+ Repeat new password
146
+ </label>
147
+ <input
148
+ id="confirm-password"
149
+ type="password"
150
+ required
151
+ autoComplete="new-password"
152
+ value={confirmation}
153
+ onChange={(event) => setConfirmation(event.target.value)}
154
+ aria-invalid={problem === 'mismatch'}
155
+ className={`input w-full ${problem === 'mismatch' ? 'input-error' : ''}`}
156
+ />
157
+ {problem !== null && (
158
+ <p id="new-password-problem" className="mt-1 text-sm text-error">
159
+ {PROBLEM_MESSAGES[problem]}
160
+ </p>
161
+ )}
162
+ </div>
163
+
164
+ <button type="submit" className="btn btn-primary" disabled={pending}>
165
+ {pending ? 'Saving…' : 'Set new password'}
166
+ </button>
167
+ </form>
168
+ );
169
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import Link from 'next/link';
3
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
4
+ import { LocaleLink } from '../LocaleLink';
4
5
 
5
6
  import { useCart } from '../../lib/cart-context';
6
7
  import type { Country } from '../../lib/forgecart';
@@ -15,7 +16,14 @@ import { CheckoutFlow } from './CheckoutFlow';
15
16
  * already in ArrangingPayment re-enters at the payment step) keeps working
16
17
  * because the hydrated active order carries its real state.
17
18
  */
18
- export function CheckoutGate({ countries }: { countries: Country[] }) {
19
+ export function CheckoutGate({
20
+ countries,
21
+ locale,
22
+ }: {
23
+ countries: Country[];
24
+ /** Threaded from the server page — a client component cannot read it. */
25
+ locale: LocaleBinding;
26
+ }) {
19
27
  const { cart, hydrated } = useCart();
20
28
 
21
29
  if (!hydrated) {
@@ -34,9 +42,9 @@ export function CheckoutGate({ countries }: { countries: Country[] }) {
34
42
  <p className="text-base-content/60">
35
43
  Your cart is empty — add something before checking out.
36
44
  </p>
37
- <Link href="/products" className="btn btn-primary">
45
+ <LocaleLink href="/products" locale={locale} className="btn btn-primary">
38
46
  Browse products
39
- </Link>
47
+ </LocaleLink>
40
48
  </div>
41
49
  );
42
50
  }