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