@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.
- package/dist/src/commands/init.d.ts +21 -2
- package/dist/src/commands/init.js +18 -2
- package/dist/src/commands/init.js.map +1 -1
- 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 +20 -1
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +150 -8
- package/templates/storefront/src/seo/redirects.ts +44 -0
- package/templates/storefront/src/server/runner.ts +1 -2
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { RouteSearchParams } from '../seo/noindex';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The anatomy of a ONE-SHOT account link (#1472).
|
|
5
|
+
*
|
|
6
|
+
* The platform mails a shopper two kinds of link and both have the same shape:
|
|
7
|
+
* this storefront's own origin, a fixed route, and a single-use token in the
|
|
8
|
+
* query — `<origin>/verify?token=…` and `<origin>/reset-password?token=…`. The
|
|
9
|
+
* `?token=` spelling is the CONTRACT between the mail templates and these
|
|
10
|
+
* pages (a merchant may have customized the mail body, so the parameter name
|
|
11
|
+
* cannot be changed on one side alone); the platform supplies the origin, this
|
|
12
|
+
* module reads what arrives.
|
|
13
|
+
*
|
|
14
|
+
* PURE BY CONTRACT: no Next imports, no `process.env`, no fetch, no I/O — the
|
|
15
|
+
* same constraint `lib/locale/grammar.ts` and `lib/seo/noindex.ts` carry, for
|
|
16
|
+
* the same reason. `tool/storefront-template-spec` imports this file directly
|
|
17
|
+
* by relative path from OUTSIDE the scaffold, so anything reachable from here
|
|
18
|
+
* must resolve without the template's Next toolchain. A rule that decides what
|
|
19
|
+
* a shopper sees after clicking a one-shot credential has to be a table of
|
|
20
|
+
* vectors rather than something only a running server can exercise. The
|
|
21
|
+
* `RouteSearchParams` import is type-only and names another pure module, so it
|
|
22
|
+
* erases and drags nothing in.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The query parameter every account link carries.
|
|
27
|
+
*
|
|
28
|
+
* Named once so the two pages and their vectors cannot disagree about the
|
|
29
|
+
* spelling the platform's mail templates emit.
|
|
30
|
+
*/
|
|
31
|
+
export const ACCOUNT_TOKEN_PARAM = 'token';
|
|
32
|
+
|
|
33
|
+
/** What the shop API answered when a token was actually presented to it. */
|
|
34
|
+
export type AccountCallOutcome = { kind: 'ok' } | { kind: 'error'; code: string };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Everything a link render has to account for: the API's answer, or the fact
|
|
38
|
+
* that there was no usable token to ask about in the first place.
|
|
39
|
+
*
|
|
40
|
+
* `no-token` is not an error the API returned — it is a property of the URL —
|
|
41
|
+
* and keeping it in the same union is what lets ONE total function decide the
|
|
42
|
+
* whole page, instead of a page-level branch plus a function that covers only
|
|
43
|
+
* the half of the cases that reached the network.
|
|
44
|
+
*/
|
|
45
|
+
export type AccountLinkOutcome = AccountCallOutcome | { kind: 'no-token' };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The single usable token in this URL, or null.
|
|
49
|
+
*
|
|
50
|
+
* Three ways to get null, each deliberate:
|
|
51
|
+
*
|
|
52
|
+
* - ABSENT — the page was opened without a link (a typed URL, or a mail
|
|
53
|
+
* client that dropped the query).
|
|
54
|
+
* - BLANK — `?token=` or `?token=%20`. Surrounding whitespace is trimmed
|
|
55
|
+
* because a mail client wrapping a long URL can deliver it padded and the
|
|
56
|
+
* platform's tokens never contain whitespace; a value that is nothing but
|
|
57
|
+
* padding is not a token.
|
|
58
|
+
* - REPEATED — `?token=a&token=b` arrives as an array, and is REFUSED rather
|
|
59
|
+
* than resolved to the first value. This is a one-shot credential: picking
|
|
60
|
+
* one of two candidates would SPEND a real token on a guess about which one
|
|
61
|
+
* the shopper meant, and the one that loses the guess is unrecoverable.
|
|
62
|
+
* Nothing legitimate produces a repeat, so the honest answer is that this
|
|
63
|
+
* URL carries no token the page may act on.
|
|
64
|
+
*/
|
|
65
|
+
export function readLinkToken(searchParams: RouteSearchParams): string | null {
|
|
66
|
+
const raw = searchParams[ACCOUNT_TOKEN_PARAM];
|
|
67
|
+
if (typeof raw === 'string') return trimmedOrNull(raw);
|
|
68
|
+
if (!Array.isArray(raw)) return null;
|
|
69
|
+
if (raw.length !== 1) return null;
|
|
70
|
+
return trimmedOrNull(raw[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function trimmedOrNull(value: string): string | null {
|
|
74
|
+
const trimmed = value.trim();
|
|
75
|
+
return trimmed === '' ? null : trimmed;
|
|
76
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { AccountCallOutcome } from './account-link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `/register` renders, as a value (#1471).
|
|
5
|
+
*
|
|
6
|
+
* PURE BY CONTRACT, for the reasons `account-link.ts` states. The form owns the
|
|
7
|
+
* COPY for each state; this module owns which state a given outcome IS, so the
|
|
8
|
+
* decision is a table of vectors and the form is a render.
|
|
9
|
+
*
|
|
10
|
+
* Unlike `/verify` and `/reset-password`, this page is not reached with a
|
|
11
|
+
* credential — a shopper types into it, and may type into it twice. That is why
|
|
12
|
+
* the states below are not a success/failure pair: the same submit, repeated,
|
|
13
|
+
* is an ORDINARY thing to happen, and what it should render is a prompt rather
|
|
14
|
+
* than an error.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The states of the register page.
|
|
19
|
+
*
|
|
20
|
+
* - `form` — the initial render, and every render after a
|
|
21
|
+
* refusal the shopper can act on.
|
|
22
|
+
* - `verify-your-email` — the account exists and awaits verification. This is
|
|
23
|
+
* the ONE state a successful registration lands in:
|
|
24
|
+
* registering never signs anyone in (the account is
|
|
25
|
+
* minted unverified and a mail is dispatched), and a
|
|
26
|
+
* storefront that called `login` here was refused
|
|
27
|
+
* NOT_VERIFIED 287 ms later (prod, 2026-09-06). A
|
|
28
|
+
* repeat submit of the same address from the same
|
|
29
|
+
* session lands here too — that is what the shop
|
|
30
|
+
* API's session-scoped idempotency buys: one account,
|
|
31
|
+
* one prompt, no error on a double-clicked button.
|
|
32
|
+
* - `already-registered` — the address belongs to an account this session did
|
|
33
|
+
* not create. It carries the resend affordance, since
|
|
34
|
+
* a shopper who never confirmed their address cannot
|
|
35
|
+
* sign in and has nowhere else to go.
|
|
36
|
+
* - `signed-in-already` — the account is verified; nothing is pending.
|
|
37
|
+
* - `failed` — the shop API was unreachable, or answered a code
|
|
38
|
+
* this template does not model.
|
|
39
|
+
*/
|
|
40
|
+
export type RegisterState =
|
|
41
|
+
| 'form'
|
|
42
|
+
| 'verify-your-email'
|
|
43
|
+
| 'already-registered'
|
|
44
|
+
| 'signed-in-already'
|
|
45
|
+
| 'failed';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The API's answer to a register submit, as a pure value.
|
|
49
|
+
*
|
|
50
|
+
* Wider than {@link AccountCallOutcome} by exactly one field, because this
|
|
51
|
+
* operation's success is not a bare yes: `verificationPending` is the shop API
|
|
52
|
+
* READING `user.verified` back, and it is the point of the #1471 contract —
|
|
53
|
+
* the two successful answers are two different screens.
|
|
54
|
+
*/
|
|
55
|
+
export type RegisterOutcome =
|
|
56
|
+
| { kind: 'ok'; verificationPending: boolean }
|
|
57
|
+
| { kind: 'error'; code: string };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `ErrorCode` → state for `registerCustomerAccount`.
|
|
61
|
+
*
|
|
62
|
+
* Both refusals collapse into ONE state, and that is a correctness requirement
|
|
63
|
+
* rather than a simplification: `IDENTIFIER_ALREADY_EXISTS` is the login
|
|
64
|
+
* identity's refusal and `EMAIL_ADDRESS_CONFLICT` the customer row's, and which
|
|
65
|
+
* one a shopper receives is decided purely by TIMING — the duplicate-address
|
|
66
|
+
* pre-check runs before the identity claim, so a resubmit arriving after the
|
|
67
|
+
* winner's customer row committed gets the second code where an in-flight twin
|
|
68
|
+
* gets the first. Rendering them differently would show two screens for one
|
|
69
|
+
* situation.
|
|
70
|
+
*
|
|
71
|
+
* `NOT_VERIFIED` is deliberately absent. That is the refusal a LOGIN gets on an
|
|
72
|
+
* unverified account, and this page signs nobody in —
|
|
73
|
+
* `registerCustomerAccount` cannot answer with it, so an arm for it would be a
|
|
74
|
+
* branch no vector can reach.
|
|
75
|
+
*
|
|
76
|
+
* Everything absent lands in `failed`: an unrecognised code is a state this
|
|
77
|
+
* template has not been taught, and guessing a friendlier one would put words
|
|
78
|
+
* in the API's mouth.
|
|
79
|
+
*/
|
|
80
|
+
const REGISTER_ERROR_STATES: Readonly<Record<string, RegisterState>> = {
|
|
81
|
+
IDENTIFIER_ALREADY_EXISTS: 'already-registered',
|
|
82
|
+
EMAIL_ADDRESS_CONFLICT: 'already-registered',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The state a submitted registration lands in. Total over the outcome union.
|
|
87
|
+
*
|
|
88
|
+
* The successful answer splits on `verificationPending` rather than assuming
|
|
89
|
+
* it: a fresh registration carrying a password is always pending, but an
|
|
90
|
+
* idempotent repeat can find the shopper verified in the meantime (they opened
|
|
91
|
+
* the link in another tab between the two submits), and sending them to their
|
|
92
|
+
* inbox for a mail they have already used is a dead end.
|
|
93
|
+
*/
|
|
94
|
+
export function registerState(outcome: RegisterOutcome): RegisterState {
|
|
95
|
+
if (outcome.kind === 'ok') {
|
|
96
|
+
return outcome.verificationPending ? 'verify-your-email' : 'signed-in-already';
|
|
97
|
+
}
|
|
98
|
+
return REGISTER_ERROR_STATES[outcome.code] ?? 'failed';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The states a verification resend lands in (`idle` is pre-submit).
|
|
103
|
+
*
|
|
104
|
+
* TWO outcomes, and the missing third is the design. The shop API throttles
|
|
105
|
+
* this endpoint — it is public and it sends mail — but it throttles SILENTLY:
|
|
106
|
+
* inside the window it answers exactly what it answers outside it, and exactly
|
|
107
|
+
* what it answers for an address that has no account at all. A `throttled`
|
|
108
|
+
* state would therefore be an enumeration tell rather than a courtesy, because
|
|
109
|
+
* the only way this storefront could render one is if the API had told it that
|
|
110
|
+
* THIS address has a pending account waiting on a recently-sent mail — the one
|
|
111
|
+
* fact the neutral answer exists to withhold. A throttled resend renders as
|
|
112
|
+
* `sent`, which is also what is true for the shopper: the mail is in their
|
|
113
|
+
* inbox, from a minute ago.
|
|
114
|
+
*
|
|
115
|
+
* `sent` is neutral for the same reason `passwordResetRequestState`'s is, and
|
|
116
|
+
* `failed` means the same thing it does there: we could not ASK, which says
|
|
117
|
+
* nothing about any account and would otherwise be a sent-mail promise we know
|
|
118
|
+
* to be false.
|
|
119
|
+
*/
|
|
120
|
+
export type VerificationResendState = 'idle' | 'sent' | 'failed';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The state a submitted resend lands in.
|
|
124
|
+
*
|
|
125
|
+
* Its own function rather than an alias of `passwordResetRequestState`: the two
|
|
126
|
+
* surfaces have different pre-submit members (`idle` here, `form` there), and
|
|
127
|
+
* aliasing them would make a change to one silently change the other — that
|
|
128
|
+
* these two answers have the same shape today is a fact about the platform's
|
|
129
|
+
* anti-enumeration posture, not a dependency between the pages.
|
|
130
|
+
*/
|
|
131
|
+
export function verificationResendState(outcome: AccountCallOutcome): VerificationResendState {
|
|
132
|
+
return outcome.kind === 'ok' ? 'sent' : 'failed';
|
|
133
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { AccountCallOutcome } from './account-link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `/reset-password` renders, as a value (#1472).
|
|
5
|
+
*
|
|
6
|
+
* One route, two modes — the URL's token decides which. WITH a token the page
|
|
7
|
+
* sets a new password; WITHOUT one it asks for an e-mail address and has a new
|
|
8
|
+
* link sent. Both modes start on their form and move to a terminal state when
|
|
9
|
+
* the API answers, so each mode gets its own union rather than one flat set
|
|
10
|
+
* that would let a request-mode render claim a reset-mode state.
|
|
11
|
+
*
|
|
12
|
+
* PURE BY CONTRACT, for the reasons `account-link.ts` states.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The states a SUBMITTED new password lands in (`form` is pre-submit). */
|
|
16
|
+
export type ResetPasswordSubmitState = 'done' | 'link-invalid' | 'password-rejected' | 'failed';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The set-a-new-password mode.
|
|
20
|
+
*
|
|
21
|
+
* - `form` — the initial render, and every render after a
|
|
22
|
+
* refusal the shopper can act on.
|
|
23
|
+
* - `done` — the password was changed.
|
|
24
|
+
* - `link-invalid` — the API refused the token: used, expired, or issued
|
|
25
|
+
* by another store. One state, same reasons as the
|
|
26
|
+
* verify page's.
|
|
27
|
+
* - `password-rejected` — the API refused the PASSWORD. The form stays up:
|
|
28
|
+
* the token is still good and the shopper only has to
|
|
29
|
+
* choose again.
|
|
30
|
+
* - `failed` — unreachable, or a code this template does not model.
|
|
31
|
+
*/
|
|
32
|
+
export type ResetPasswordState = 'form' | ResetPasswordSubmitState;
|
|
33
|
+
|
|
34
|
+
/** The states a SUBMITTED reset request lands in (`form` is pre-submit). */
|
|
35
|
+
export type PasswordResetRequestSubmitState = 'sent' | 'failed';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The ask-for-a-link mode.
|
|
39
|
+
*
|
|
40
|
+
* `sent` is deliberately NEUTRAL and covers both of the API's successful
|
|
41
|
+
* answers: it reports whether an account matched, and rendering that difference
|
|
42
|
+
* would turn this form into an account-enumeration oracle — type an address,
|
|
43
|
+
* read the page, learn whether that person shops here. `failed` is reserved for
|
|
44
|
+
* the case where we could not ASK at all, which tells a visitor nothing about
|
|
45
|
+
* any account and would otherwise be a lie dressed as a sent mail.
|
|
46
|
+
*/
|
|
47
|
+
export type PasswordResetRequestState = 'form' | PasswordResetRequestSubmitState;
|
|
48
|
+
|
|
49
|
+
/** Why a new-password form cannot be submitted yet. */
|
|
50
|
+
export type NewPasswordProblem = 'empty' | 'mismatch';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `ErrorCode` → state for `resetPassword`.
|
|
54
|
+
*
|
|
55
|
+
* `CUSTOMER_NOT_FOUND` is NOT the wrong-store case here, unlike on the verify
|
|
56
|
+
* page: consuming a reset token is scoped to the principal class of the
|
|
57
|
+
* requesting store, so another store's token simply does not resolve and
|
|
58
|
+
* arrives as `PASSWORD_RESET_TOKEN_INVALID`. Reaching `CUSTOMER_NOT_FOUND`
|
|
59
|
+
* means the token DID resolve inside this store and the customer row behind it
|
|
60
|
+
* is missing — a platform-side inconsistency, not something a shopper can act
|
|
61
|
+
* on — so it belongs in `failed` with the rest of the unmodelled codes.
|
|
62
|
+
*/
|
|
63
|
+
const RESET_ERROR_STATES: Readonly<Record<string, ResetPasswordSubmitState>> = {
|
|
64
|
+
PASSWORD_RESET_TOKEN_INVALID: 'link-invalid',
|
|
65
|
+
PASSWORD_RESET_TOKEN_EXPIRED: 'link-invalid',
|
|
66
|
+
PASSWORD_VALIDATION_FAILED: 'password-rejected',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The state a submitted new password lands in. */
|
|
70
|
+
export function resetPasswordState(outcome: AccountCallOutcome): ResetPasswordSubmitState {
|
|
71
|
+
if (outcome.kind === 'ok') return 'done';
|
|
72
|
+
return RESET_ERROR_STATES[outcome.code] ?? 'failed';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The state a submitted reset REQUEST lands in.
|
|
77
|
+
*
|
|
78
|
+
* Every answer the API gives is `sent`; only a failure to reach it is anything
|
|
79
|
+
* else. See {@link PasswordResetRequestState} for why the successful answers
|
|
80
|
+
* are collapsed.
|
|
81
|
+
*/
|
|
82
|
+
export function passwordResetRequestState(
|
|
83
|
+
outcome: AccountCallOutcome,
|
|
84
|
+
): PasswordResetRequestSubmitState {
|
|
85
|
+
return outcome.kind === 'ok' ? 'sent' : 'failed';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether the two password fields may be submitted, and if not, why.
|
|
90
|
+
*
|
|
91
|
+
* A UX affordance, NOT a security boundary, and the distinction is the whole
|
|
92
|
+
* reason the confirmation never leaves the browser: the shop API is the only
|
|
93
|
+
* authority on whether a password is acceptable (it answers
|
|
94
|
+
* `PASSWORD_VALIDATION_FAILED`, which arrives here as `password-rejected`), and
|
|
95
|
+
* a Server Action is a public endpoint that a caller can reach without ever
|
|
96
|
+
* rendering this form. Checking the confirmation client-side saves a shopper a
|
|
97
|
+
* round-trip and a spent token; it decides nothing the server would not decide
|
|
98
|
+
* again.
|
|
99
|
+
*
|
|
100
|
+
* The password is compared UNTRIMMED. Leading and trailing spaces are legal
|
|
101
|
+
* characters in a password, so trimming here would silently accept two values
|
|
102
|
+
* the API will treat as different.
|
|
103
|
+
*/
|
|
104
|
+
export function checkNewPassword(
|
|
105
|
+
password: string,
|
|
106
|
+
confirmation: string,
|
|
107
|
+
): NewPasswordProblem | null {
|
|
108
|
+
if (password.length === 0) return 'empty';
|
|
109
|
+
if (password !== confirmation) return 'mismatch';
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AccountLinkOutcome } from './account-link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `/verify` renders, as a value (#1472).
|
|
5
|
+
*
|
|
6
|
+
* PURE BY CONTRACT, for the reasons `account-link.ts` states. The page owns the
|
|
7
|
+
* COPY for each state; this module owns which state a given outcome is, so the
|
|
8
|
+
* decision is a table of vectors and the page is a render.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The states of the e-mail verification page.
|
|
13
|
+
*
|
|
14
|
+
* - `no-token` — the URL carried no token this page may act on.
|
|
15
|
+
* - `verified` — the account is now verified.
|
|
16
|
+
* - `link-invalid` — the API refused the token: already used, expired, or
|
|
17
|
+
* never issued. They are ONE state on purpose — the page's
|
|
18
|
+
* advice is identical ("request a new mail") and telling an
|
|
19
|
+
* unauthenticated visitor whether a token they do not hold
|
|
20
|
+
* once existed is an oracle with no upside.
|
|
21
|
+
* - `wrong-store` — the token was valid, but the account it belongs to is not
|
|
22
|
+
* a customer of THIS store.
|
|
23
|
+
* - `failed` — anything else: the shop API was unreachable, or answered
|
|
24
|
+
* a code this template does not model.
|
|
25
|
+
*/
|
|
26
|
+
export type VerifyState = 'no-token' | 'verified' | 'link-invalid' | 'wrong-store' | 'failed';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `ErrorCode` → state, for the codes `verifyCustomerAccount` can answer with.
|
|
30
|
+
*
|
|
31
|
+
* `CUSTOMER_NOT_FOUND` is the `wrong-store` case and the reason that state
|
|
32
|
+
* exists. Verification resolves the token GLOBALLY (the platform's auth method
|
|
33
|
+
* is keyed by the token alone), and only the customer lookup that follows is
|
|
34
|
+
* scoped to the channel this storefront serves — so a token issued by another
|
|
35
|
+
* store passes the first step and fails the second. That is exactly what a
|
|
36
|
+
* shopper holding an OLD mail hits: before #1472 every store's links pointed at
|
|
37
|
+
* one platform-global host, so a stale link can still arrive at the wrong
|
|
38
|
+
* storefront. "Something went wrong" would be true and useless; naming the
|
|
39
|
+
* cause is what lets the shopper act on it.
|
|
40
|
+
*
|
|
41
|
+
* Everything absent from this table lands in `failed` by design: an
|
|
42
|
+
* unrecognised code is a state this template has not been taught, and guessing
|
|
43
|
+
* at a friendlier one would put words in the API's mouth.
|
|
44
|
+
*/
|
|
45
|
+
const VERIFY_ERROR_STATES: Readonly<Record<string, VerifyState>> = {
|
|
46
|
+
VERIFICATION_TOKEN_INVALID: 'link-invalid',
|
|
47
|
+
VERIFICATION_TOKEN_EXPIRED: 'link-invalid',
|
|
48
|
+
CUSTOMER_NOT_FOUND: 'wrong-store',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The state a verification render lands in. Total over the outcome union. */
|
|
52
|
+
export function verifyState(outcome: AccountLinkOutcome): VerifyState {
|
|
53
|
+
if (outcome.kind === 'no-token') return 'no-token';
|
|
54
|
+
if (outcome.kind === 'ok') return 'verified';
|
|
55
|
+
return VERIFY_ERROR_STATES[outcome.code] ?? 'failed';
|
|
56
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import 'server-only';
|
|
4
|
+
|
|
5
|
+
import { verificationResendState, type VerificationResendState } from './account/register-state';
|
|
6
|
+
import {
|
|
7
|
+
passwordResetRequestState,
|
|
8
|
+
resetPasswordState,
|
|
9
|
+
type PasswordResetRequestSubmitState,
|
|
10
|
+
type ResetPasswordSubmitState,
|
|
11
|
+
} from './account/reset-password-state';
|
|
12
|
+
import { requestCustomerVerification, requestPasswordReset, resetPassword } from './forgecart';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Server Actions behind the account forms (#1472, #1471).
|
|
16
|
+
*
|
|
17
|
+
* These are the places in this template where a SHOPPER's input reaches the
|
|
18
|
+
* shop API from the server rather than over their own websocket, and that
|
|
19
|
+
* placement is deliberate: all three operations are unauthenticated by design
|
|
20
|
+
* (whoever holds the token, or the address, may call them), so putting them on
|
|
21
|
+
* the session socket would open a session for someone who has not shopped yet
|
|
22
|
+
* and attach a cart identity to a password reset. They run on the anonymous
|
|
23
|
+
* channel singleton in `lib/forgecart.ts` instead.
|
|
24
|
+
*
|
|
25
|
+
* The register SUBMIT is the one account operation that does NOT belong here,
|
|
26
|
+
* for the mirror image of that reason: it is session-scoped, and the session
|
|
27
|
+
* has to be the shopper's own. It lives in `lib/account-session.ts`, which
|
|
28
|
+
* states that argument in full.
|
|
29
|
+
*
|
|
30
|
+
* Each action returns the page STATE, not the API's envelope: the mapping from
|
|
31
|
+
* `ErrorCode` to state lives once, in `lib/account/reset-password-state.ts`,
|
|
32
|
+
* and a string union crosses the RSC boundary as itself. The client form only
|
|
33
|
+
* has to render what it is given.
|
|
34
|
+
*
|
|
35
|
+
* A Server Action is a public HTTP endpoint — a caller can reach these without
|
|
36
|
+
* ever loading the form — which is exactly why neither of them validates
|
|
37
|
+
* anything the API validates: the token and the password are judged by the
|
|
38
|
+
* shop API, and the form's own confirmation check is a UX affordance that
|
|
39
|
+
* never leaves the browser (see `checkNewPassword`). Abuse budgets (how often
|
|
40
|
+
* an address may be mailed) belong to the platform, which owns the mail — and
|
|
41
|
+
* for the verification resend it enforces one silently, which is why no state
|
|
42
|
+
* here names it (see {@link VerificationResendState}).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** Consume a reset token and set the new password. */
|
|
46
|
+
export async function submitPasswordReset(input: {
|
|
47
|
+
token: string;
|
|
48
|
+
password: string;
|
|
49
|
+
}): Promise<ResetPasswordSubmitState> {
|
|
50
|
+
return resetPasswordState(await resetPassword(input));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Ask for a reset link.
|
|
55
|
+
*
|
|
56
|
+
* Answers the same neutral state whether or not an account matched — see
|
|
57
|
+
* {@link passwordResetRequestState}.
|
|
58
|
+
*/
|
|
59
|
+
export async function submitPasswordResetRequest(
|
|
60
|
+
emailAddress: string,
|
|
61
|
+
): Promise<PasswordResetRequestSubmitState> {
|
|
62
|
+
return passwordResetRequestState(await requestPasswordReset(emailAddress));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Ask for the verification mail again (#1471).
|
|
67
|
+
*
|
|
68
|
+
* Answers the same neutral state whether or not the address has anything to
|
|
69
|
+
* verify, and whether or not the platform's throttle swallowed this particular
|
|
70
|
+
* request — see {@link VerificationResendState}.
|
|
71
|
+
*/
|
|
72
|
+
export async function submitVerificationRequest(
|
|
73
|
+
emailAddress: string,
|
|
74
|
+
): Promise<VerificationResendState> {
|
|
75
|
+
return verificationResendState(await requestCustomerVerification(emailAddress));
|
|
76
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { RegisterCustomerInput } from '@forgecart/sdk/shop';
|
|
4
|
+
|
|
5
|
+
import type { RegisterOutcome } from './account/register-state';
|
|
6
|
+
import { runSessionOp } from './shop-session';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Registering, on the shopper's OWN websocket (#1471).
|
|
10
|
+
*
|
|
11
|
+
* Every other unauthenticated account call in this template is a Server Action
|
|
12
|
+
* running on the anonymous channel singleton (`lib/account-actions.ts`). This
|
|
13
|
+
* one is not, and the placement is the design rather than a preference:
|
|
14
|
+
*
|
|
15
|
+
* 1. CORRECTNESS. `lib/forgecart.ts` memoizes ONE client per language for the
|
|
16
|
+
* whole Next process, so every visitor's Server Action shares it — and
|
|
17
|
+
* therefore shares its shop session. The shop API's register idempotency is
|
|
18
|
+
* SESSION-scoped (a session holds one registration claim, which is what
|
|
19
|
+
* turns a double-clicked submit into one account and one prompt instead of
|
|
20
|
+
* a refusal), so running it on a shared session would answer one shopper
|
|
21
|
+
* with another shopper's pending state. The guarantee is only sound on a
|
|
22
|
+
* session that belongs to one person.
|
|
23
|
+
* 2. INTENT. Registering starts the shopper relationship, and the anonymous
|
|
24
|
+
* session this socket holds IS the cart identity that logging in later
|
|
25
|
+
* promotes — so the cart someone filled before registering follows them
|
|
26
|
+
* into their account.
|
|
27
|
+
*
|
|
28
|
+
* That is the opposite placement from `/verify` and `/reset-password`, and
|
|
29
|
+
* deliberately so: those act on a one-shot token out of a mail, where opening a
|
|
30
|
+
* session for a visitor who has not shopped yet would attach a cart identity to
|
|
31
|
+
* a password reset. The verification RESEND on this page is in that second
|
|
32
|
+
* category too — it needs nothing but an address — so it stays a Server Action.
|
|
33
|
+
*/
|
|
34
|
+
export async function registerCustomerAccount(
|
|
35
|
+
input: RegisterCustomerInput,
|
|
36
|
+
): Promise<RegisterOutcome> {
|
|
37
|
+
const result = await runSessionOp(
|
|
38
|
+
async (client) =>
|
|
39
|
+
(await client.customer.registerCustomerAccount({ input })).registerCustomerAccount,
|
|
40
|
+
);
|
|
41
|
+
// `runSessionOp` never throws — a refusal arrives as a typed code, which is
|
|
42
|
+
// the ordinary case here (the address may already be registered) and the form
|
|
43
|
+
// renders it. The envelope is flattened into the pure outcome union so the
|
|
44
|
+
// state table stays the only thing that reads an ErrorCode.
|
|
45
|
+
if (!result.ok) return { kind: 'error', code: result.error.code };
|
|
46
|
+
return { kind: 'ok', verificationPending: result.data.verificationPending };
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `alt` attribute for an asset-backed `<img>` — the one place the template
|
|
3
|
+
* decides what an undescribed image announces (#1376).
|
|
4
|
+
*
|
|
5
|
+
* Alt text is the merchant's OWN description of the image, authored in the
|
|
6
|
+
* dashboard's media library. Its absence is therefore a decision rather than a
|
|
7
|
+
* gap: an image nobody described is announced as decorative (`alt=""`) and
|
|
8
|
+
* skipped by screen readers, which is exactly right for the product shots,
|
|
9
|
+
* badges and thumbnails that repeat text already sitting next to them.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately NEVER back-filled with the product or variant name. A
|
|
12
|
+
* derived placeholder is indistinguishable from a real description to both a
|
|
13
|
+
* screen reader and a crawler, while carrying nothing the adjacent markup does
|
|
14
|
+
* not already say — so it spends a listener's attention repeating the caption
|
|
15
|
+
* they just heard, and tells a crawler the image was described when it was
|
|
16
|
+
* not. The empty string is the honest answer and the accessible one, and this
|
|
17
|
+
* function exists so that judgement is stated once instead of re-derived at
|
|
18
|
+
* every `<img>` (all four of which read `alt={product.name}` before it).
|
|
19
|
+
*
|
|
20
|
+
* A blank-but-present description collapses to the same decorative default.
|
|
21
|
+
* `alt=" "` is the worst of both worlds — not a description, and not the
|
|
22
|
+
* empty string that marks an image skippable, so a reader falls back to
|
|
23
|
+
* announcing the file name. The dashboard already trims before it writes, but
|
|
24
|
+
* the field is equally writable through the admin API, so the guarantee is
|
|
25
|
+
* made here where it is actually rendered rather than assumed upstream.
|
|
26
|
+
*
|
|
27
|
+
* The parameter is structural, not the SDK's `Asset`: every selection that
|
|
28
|
+
* renders an image picks `altText` alongside `preview`, and asking only for
|
|
29
|
+
* the field actually read keeps this module free of the SDK import — which is
|
|
30
|
+
* what lets it be unit-tested directly (see tool/storefront-template-spec).
|
|
31
|
+
*/
|
|
32
|
+
export function getAssetAlt(asset: { altText?: string | null }): string {
|
|
33
|
+
return asset.altText?.trim() ?? '';
|
|
34
|
+
}
|