@forgecart/cli 2.202608221935.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.
- package/dist/src/cli.js +3 -21
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
- package/dist/src/commands/__test__/cli-harness.js +29 -0
- package/dist/src/commands/__test__/cli-harness.js.map +1 -0
- package/dist/src/commands/init.d.ts +28 -2
- package/dist/src/commands/init.js +100 -15
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/refresh.d.ts +40 -0
- package/dist/src/commands/refresh.js +147 -0
- package/dist/src/commands/refresh.js.map +1 -0
- package/dist/src/commands/template-manifest.d.ts +38 -0
- package/dist/src/commands/template-manifest.js +116 -0
- package/dist/src/commands/template-manifest.js.map +1 -0
- package/dist/src/version.d.ts +10 -0
- package/dist/src/version.js +25 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +1 -1
- package/templates/storefront/README.md +42 -4
- package/templates/storefront/next.config.js +29 -7
- package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
- package/templates/storefront/src/app/cart/page.tsx +14 -2
- package/templates/storefront/src/app/checkout/page.tsx +14 -2
- package/templates/storefront/src/app/layout.tsx +85 -22
- package/templates/storefront/src/app/page.tsx +63 -20
- package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
- package/templates/storefront/src/app/ping/route.ts +1 -1
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
- package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
- package/templates/storefront/src/app/products/page.tsx +41 -6
- package/templates/storefront/src/app/register/page.tsx +54 -0
- package/templates/storefront/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront/src/app/robots.ts +69 -0
- package/templates/storefront/src/app/sitemap.ts +106 -0
- package/templates/storefront/src/app/verify/page.tsx +155 -0
- package/templates/storefront/src/components/CartView.tsx +26 -7
- package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
- package/templates/storefront/src/components/Header.tsx +30 -10
- package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
- package/templates/storefront/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront/src/components/ProductCard.tsx +10 -4
- package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
- package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
- package/templates/storefront/src/lib/account/account-link.ts +76 -0
- package/templates/storefront/src/lib/account/register-state.ts +133 -0
- package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront/src/lib/account-actions.ts +76 -0
- package/templates/storefront/src/lib/account-session.ts +47 -0
- package/templates/storefront/src/lib/asset-alt.ts +34 -0
- package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront/src/lib/experiments.ts +1 -1
- package/templates/storefront/src/lib/forgecart.ts +300 -27
- package/templates/storefront/src/lib/format.ts +12 -14
- package/templates/storefront/src/lib/identify-forward.ts +152 -0
- package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront/src/lib/marketing-params.ts +213 -0
- package/templates/storefront/src/lib/money.ts +50 -0
- package/templates/storefront/src/lib/seo/alternates.ts +120 -0
- package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront/src/lib/seo/metadata.ts +323 -0
- package/templates/storefront/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
- package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront/src/lib/session-actions.ts +15 -8
- package/templates/storefront/src/lib/session-cookies.ts +98 -0
- package/templates/storefront/src/lib/shop-config.ts +9 -2
- package/templates/storefront/src/lib/shop-session.ts +42 -5
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +196 -16
- package/templates/storefront/src/seo/redirects.ts +44 -0
- 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
|
|
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({
|
|
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
|
-
<
|
|
45
|
+
<LocaleLink href="/products" locale={locale} className="btn btn-primary">
|
|
38
46
|
Browse products
|
|
39
|
-
</
|
|
47
|
+
</LocaleLink>
|
|
40
48
|
</div>
|
|
41
49
|
);
|
|
42
50
|
}
|