@forgecart/cli 2.202610141814.0 → 2.202610142335.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/admin-api/admin-api.answer.d.ts +26 -0
- package/dist/src/admin-api/admin-api.answer.js +43 -0
- package/dist/src/admin-api/admin-api.answer.js.map +1 -0
- package/dist/src/admin-api/admin-api.client.d.ts +6 -4
- package/dist/src/admin-api/admin-api.client.js +19 -15
- package/dist/src/admin-api/admin-api.client.js.map +1 -1
- package/dist/src/admin-api/admin-api.result.d.ts +15 -9
- package/dist/src/admin-api/admin-api.result.js +45 -30
- package/dist/src/admin-api/admin-api.result.js.map +1 -1
- package/dist/src/authorization/cli-authorization.client.d.ts +4 -3
- package/dist/src/authorization/cli-authorization.client.js +18 -13
- package/dist/src/authorization/cli-authorization.client.js.map +1 -1
- package/dist/src/commands/sync.js +1 -1
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/sync/sync-bundle-push.d.ts +15 -3
- package/dist/src/sync/sync-bundle-push.js +45 -2
- package/dist/src/sync/sync-bundle-push.js.map +1 -1
- package/dist/src/sync/sync-record.file.d.ts +27 -3
- package/dist/src/sync/sync-record.file.js +77 -25
- package/dist/src/sync/sync-record.file.js.map +1 -1
- package/dist/src/sync/sync-rejection.d.ts +24 -0
- package/dist/src/sync/sync-rejection.js +58 -0
- package/dist/src/sync/sync-rejection.js.map +1 -0
- package/dist/src/sync/sync-repository.d.ts +9 -1
- package/dist/src/sync/sync-repository.js +23 -1
- package/dist/src/sync/sync-repository.js.map +1 -1
- package/dist/src/sync/sync-round-follow.d.ts +42 -0
- package/dist/src/sync/sync-round-follow.js +128 -0
- package/dist/src/sync/sync-round-follow.js.map +1 -0
- package/dist/src/sync/sync-round.d.ts +49 -8
- package/dist/src/sync/sync-round.js +134 -27
- package/dist/src/sync/sync-round.js.map +1 -1
- package/dist/src/sync/sync.runner.d.ts +4 -2
- package/dist/src/sync/sync.runner.js +6 -3
- package/dist/src/sync/sync.runner.js.map +1 -1
- package/dist/src/sync/type/sync.types.d.ts +78 -2
- package/dist/src/sync/type/sync.types.js +31 -0
- package/dist/src/sync/type/sync.types.js.map +1 -1
- package/dist/src/sync/workspace-sync.client.d.ts +29 -6
- package/dist/src/sync/workspace-sync.client.js +141 -21
- package/dist/src/sync/workspace-sync.client.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +52 -5
- package/templates/storefront/src/app/account/page.tsx +51 -0
- package/templates/storefront/src/app/layout.tsx +4 -5
- package/templates/storefront/src/app/verify/page.tsx +16 -4
- package/templates/storefront/src/components/Header.tsx +13 -1
- package/templates/storefront/src/components/account/AccountPanel.tsx +289 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +15 -66
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +2 -2
- package/templates/storefront/src/components/account/VerificationResend.tsx +96 -0
- package/templates/storefront/src/lib/account/account-state.ts +152 -0
- package/templates/storefront/src/lib/account-session.ts +83 -16
- package/templates/storefront/src/lib/error-messages.ts +102 -8
- package/templates/storefront/src/lib/seo/noindex.ts +5 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +2 -0
- package/templates/storefront/src/lib/shop-session.ts +71 -18
- package/templates/storefront-shadcn/README.md +52 -5
- package/templates/storefront-shadcn/src/app/account/page.tsx +51 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +4 -5
- package/templates/storefront-shadcn/src/app/verify/page.tsx +16 -4
- package/templates/storefront-shadcn/src/components/Header.tsx +17 -1
- package/templates/storefront-shadcn/src/components/account/AccountPanel.tsx +290 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +19 -61
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +2 -2
- package/templates/storefront-shadcn/src/components/account/VerificationResend.tsx +92 -0
- package/templates/storefront-shadcn/src/lib/account/account-state.ts +152 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +83 -16
- package/templates/storefront-shadcn/src/lib/error-messages.ts +102 -8
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +5 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +2 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +71 -18
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { AccountCallOutcome } from './account-link';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `/account` renders, as a value (#1093).
|
|
5
|
+
*
|
|
6
|
+
* PURE BY CONTRACT, for the reasons `account-link.ts` states. The panel owns the
|
|
7
|
+
* COPY; this module owns which view a given answer of the shop API IS, so every
|
|
8
|
+
* decision the sign-in surface makes is a table of vectors and the panel is a
|
|
9
|
+
* render.
|
|
10
|
+
*
|
|
11
|
+
* The page is a static shell and the session lives on the shopper's own socket,
|
|
12
|
+
* so nothing here is known at render time: the server paints `loading`, and the
|
|
13
|
+
* panel asks the socket who is signed in once the cart provider has armed it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The shopper a signed-in session belongs to, as the account page greets them. */
|
|
17
|
+
export interface SignedInCustomer {
|
|
18
|
+
readonly firstName: string;
|
|
19
|
+
readonly lastName: string;
|
|
20
|
+
readonly emailAddress: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the shop API answered when asked who this socket's session belongs to.
|
|
25
|
+
*
|
|
26
|
+
* `customer: null` is an ordinary answer, not an error: a socket carrying no
|
|
27
|
+
* session, and an anonymous cart session, both belong to nobody.
|
|
28
|
+
*/
|
|
29
|
+
export type ActiveCustomerOutcome =
|
|
30
|
+
| { kind: 'ok'; customer: SignedInCustomer | null }
|
|
31
|
+
| { kind: 'error'; code: string };
|
|
32
|
+
|
|
33
|
+
/** The call a refusal answered — what the panel says went wrong. */
|
|
34
|
+
export type AccountStep = 'greeting' | 'sign-in' | 'sign-out';
|
|
35
|
+
|
|
36
|
+
/** A refusal the panel renders, with the call it answered. */
|
|
37
|
+
export interface AccountRefusal {
|
|
38
|
+
readonly step: AccountStep;
|
|
39
|
+
readonly code: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The views of the account page — each `state` is the `data-fc-account-state`
|
|
44
|
+
* the panel stamps.
|
|
45
|
+
*
|
|
46
|
+
* - `loading` — the server render, and every client render until the
|
|
47
|
+
* socket has been asked. A skeleton, never the form: a
|
|
48
|
+
* returning customer must land on their greeting without a
|
|
49
|
+
* flash of the sign-in form first.
|
|
50
|
+
* - `signed-out` — the sign-in form, with the refusal that put it there, if
|
|
51
|
+
* any.
|
|
52
|
+
* - `signed-in` — the greeting and the way out.
|
|
53
|
+
*/
|
|
54
|
+
export type AccountView =
|
|
55
|
+
| { readonly state: 'loading' }
|
|
56
|
+
| { readonly state: 'signed-out'; readonly refusal: AccountRefusal | null }
|
|
57
|
+
| { readonly state: 'signed-in'; readonly customer: SignedInCustomer };
|
|
58
|
+
|
|
59
|
+
/** The view the server renders. */
|
|
60
|
+
export const LOADING_VIEW: AccountView = { state: 'loading' };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The codes that mean the session the socket presented is OVER: the API holds
|
|
64
|
+
* no session for the token (`SESSION_NOT_FOUND`), or the request carried none
|
|
65
|
+
* it could use (`UNAUTHORIZED` — the guard's answer for a token that resolves
|
|
66
|
+
* to no session).
|
|
67
|
+
*
|
|
68
|
+
* A session that is over is a shopper who is signed out, not a fault: the
|
|
69
|
+
* greeting renders the form without a message, and a sign-out that finds the
|
|
70
|
+
* session already gone has done what it was asked.
|
|
71
|
+
*/
|
|
72
|
+
const SESSION_GONE_CODES: ReadonlySet<string> = new Set(['UNAUTHORIZED', 'SESSION_NOT_FOUND']);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The refusal of a sign-in whose session greets nobody: the credentials were
|
|
76
|
+
* accepted, but the session belongs to no customer of THIS store — the code
|
|
77
|
+
* `/verify`'s `wrong-store` state reads for the same situation.
|
|
78
|
+
*/
|
|
79
|
+
const NO_CUSTOMER_CODE = 'CUSTOMER_NOT_FOUND';
|
|
80
|
+
|
|
81
|
+
/** The refusal that a confirmation mail answers. */
|
|
82
|
+
const NOT_VERIFIED_CODE = 'NOT_VERIFIED';
|
|
83
|
+
|
|
84
|
+
/** Whether a refusal says the session it was asked about no longer exists. */
|
|
85
|
+
export function isSessionGone(code: string): boolean {
|
|
86
|
+
return SESSION_GONE_CODES.has(code);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The view the page lands in when it asks who is signed in on arrival.
|
|
91
|
+
*
|
|
92
|
+
* A session that greets nobody — none on the socket, an anonymous cart session,
|
|
93
|
+
* or one that is over — is the sign-in form with nothing to apologise for. Any
|
|
94
|
+
* other refusal is shown: the form still renders, because a shopper who cannot
|
|
95
|
+
* be greeted can still try to sign in.
|
|
96
|
+
*/
|
|
97
|
+
export function greetingView(outcome: ActiveCustomerOutcome): AccountView {
|
|
98
|
+
if (outcome.kind === 'ok' && outcome.customer !== null) {
|
|
99
|
+
return { state: 'signed-in', customer: outcome.customer };
|
|
100
|
+
}
|
|
101
|
+
if (outcome.kind === 'ok' || isSessionGone(outcome.code)) {
|
|
102
|
+
return { state: 'signed-out', refusal: null };
|
|
103
|
+
}
|
|
104
|
+
return { state: 'signed-out', refusal: { step: 'greeting', code: outcome.code } };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The view an ACCEPTED sign-in lands in, from the greeting asked right after it.
|
|
109
|
+
*
|
|
110
|
+
* Unlike {@link greetingView}, "nobody is signed in" is never a silent answer
|
|
111
|
+
* here: the shopper has just been told yes, so a session that greets nobody is
|
|
112
|
+
* a failed sign-in and is rendered as one. A session that is gone the moment it
|
|
113
|
+
* was minted is the #1089 signature — a socket still speaking for the session
|
|
114
|
+
* it opened with — and is shown with its code rather than folded away.
|
|
115
|
+
*/
|
|
116
|
+
export function signInView(outcome: ActiveCustomerOutcome): AccountView {
|
|
117
|
+
if (outcome.kind === 'error') {
|
|
118
|
+
return { state: 'signed-out', refusal: { step: 'sign-in', code: outcome.code } };
|
|
119
|
+
}
|
|
120
|
+
if (outcome.customer === null) {
|
|
121
|
+
return { state: 'signed-out', refusal: { step: 'sign-in', code: NO_CUSTOMER_CODE } };
|
|
122
|
+
}
|
|
123
|
+
return { state: 'signed-in', customer: outcome.customer };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The view a REFUSED sign-in lands in. */
|
|
127
|
+
export function signInRefusedView(code: string): AccountView {
|
|
128
|
+
return { state: 'signed-out', refusal: { step: 'sign-in', code } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The view a sign-out lands in. Always `signed-out` — the session helper has
|
|
133
|
+
* left this tab without a session whatever the API answered — and a refusal is
|
|
134
|
+
* shown only when the API could not confirm the session ended.
|
|
135
|
+
*/
|
|
136
|
+
export function signedOutView(outcome: AccountCallOutcome): AccountView {
|
|
137
|
+
if (outcome.kind === 'ok') return { state: 'signed-out', refusal: null };
|
|
138
|
+
return { state: 'signed-out', refusal: { step: 'sign-out', code: outcome.code } };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether the view offers to send the confirmation mail again: only after a
|
|
143
|
+
* sign-in the API refused because the account was never confirmed. Without it
|
|
144
|
+
* that shopper has nowhere to go — the mail they lost is the only key.
|
|
145
|
+
*/
|
|
146
|
+
export function offersVerificationResend(view: AccountView): boolean {
|
|
147
|
+
return (
|
|
148
|
+
view.state === 'signed-out' &&
|
|
149
|
+
view.refusal?.step === 'sign-in' &&
|
|
150
|
+
view.refusal.code === NOT_VERIFIED_CODE
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type { RegisterCustomerInput } from '@forgecart/sdk/shop';
|
|
3
|
+
import type { NativeAuthInput, RegisterCustomerInput } from '@forgecart/sdk/shop';
|
|
4
4
|
|
|
5
|
+
import type { AccountCallOutcome } from './account/account-link';
|
|
6
|
+
import { isSessionGone, type ActiveCustomerOutcome } from './account/account-state';
|
|
5
7
|
import type { RegisterOutcome } from './account/register-state';
|
|
6
|
-
import { runSessionOp } from './shop-session';
|
|
8
|
+
import { replaceShopSession, runSessionOp } from './shop-session';
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
*
|
|
11
|
+
* The account calls that run on the shopper's OWN websocket: registering
|
|
12
|
+
* (#1471), and signing in and out (#1093).
|
|
10
13
|
*
|
|
11
14
|
* Every other unauthenticated account call in this template is a Server Action
|
|
12
|
-
* running on the anonymous channel singleton (`lib/account-actions.ts`).
|
|
13
|
-
*
|
|
15
|
+
* running on the anonymous channel singleton (`lib/account-actions.ts`). These
|
|
16
|
+
* are not, and the placement is the design rather than a preference:
|
|
14
17
|
*
|
|
15
18
|
* 1. CORRECTNESS. `lib/forgecart.ts` memoizes ONE client per language for the
|
|
16
19
|
* whole Next process, so every visitor's Server Action shares it — and
|
|
@@ -18,19 +21,26 @@ import { runSessionOp } from './shop-session';
|
|
|
18
21
|
* SESSION-scoped (a session holds one registration claim, which is what
|
|
19
22
|
* turns a double-clicked submit into one account and one prompt instead of
|
|
20
23
|
* a refusal), so running it on a shared session would answer one shopper
|
|
21
|
-
* with another shopper's pending state.
|
|
22
|
-
*
|
|
24
|
+
* with another shopper's pending state. A sign-in on that client would be
|
|
25
|
+
* worse still: it would sign EVERY visitor in as whoever signed in last.
|
|
26
|
+
* These calls are only sound on a session that belongs to one person.
|
|
23
27
|
* 2. INTENT. Registering starts the shopper relationship, and the anonymous
|
|
24
|
-
* session this socket holds IS the cart identity that
|
|
25
|
-
* promotes — so the cart someone filled before registering
|
|
26
|
-
* into their account.
|
|
28
|
+
* session this socket holds IS the cart identity that signing in later
|
|
29
|
+
* promotes — so the cart someone filled before registering, or before
|
|
30
|
+
* signing in, follows them into their account.
|
|
27
31
|
*
|
|
28
32
|
* That is the opposite placement from `/verify` and `/reset-password`, and
|
|
29
33
|
* deliberately so: those act on a one-shot token out of a mail, where opening a
|
|
30
34
|
* session for a visitor who has not shopped yet would attach a cart identity to
|
|
31
|
-
* a password reset. The verification RESEND
|
|
32
|
-
*
|
|
35
|
+
* a password reset. The verification RESEND is in that second category too — it
|
|
36
|
+
* needs nothing but an address — so it stays a Server Action.
|
|
37
|
+
*
|
|
38
|
+
* Like `runSessionOp`, none of these throws: a refusal is a typed code in the
|
|
39
|
+
* outcome, which is the ordinary case (a mistyped password, an address already
|
|
40
|
+
* registered), and the pure state tables in `lib/account/` stay the only things
|
|
41
|
+
* that read an ErrorCode.
|
|
33
42
|
*/
|
|
43
|
+
|
|
34
44
|
export async function registerCustomerAccount(
|
|
35
45
|
input: RegisterCustomerInput,
|
|
36
46
|
): Promise<RegisterOutcome> {
|
|
@@ -38,10 +48,67 @@ export async function registerCustomerAccount(
|
|
|
38
48
|
async (client) =>
|
|
39
49
|
(await client.customer.registerCustomerAccount({ input })).registerCustomerAccount,
|
|
40
50
|
);
|
|
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
51
|
if (!result.ok) return { kind: 'error', code: result.error.code };
|
|
46
52
|
return { kind: 'ok', verificationPending: result.data.verificationPending };
|
|
47
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Sign the shopper in.
|
|
57
|
+
*
|
|
58
|
+
* Resolves only once this tab's socket AND the session cookies carry the
|
|
59
|
+
* session the sign-in minted (`replaceShopSession`), so the caller's very next
|
|
60
|
+
* operation — the greeting — runs as the signed-in shopper. Before #1093 every
|
|
61
|
+
* storefront hand-rolled this, and the one that did not rotate the socket had
|
|
62
|
+
* its next operation refused UNAUTHORIZED and reported a failed sign-in (#1089).
|
|
63
|
+
*
|
|
64
|
+
* A refusal resolves with its code — `INVALID_CREDENTIALS`, `NOT_VERIFIED`, … —
|
|
65
|
+
* and changes nothing: the socket keeps the session it had, cart included.
|
|
66
|
+
*/
|
|
67
|
+
export async function loginCustomer(input: NativeAuthInput): Promise<AccountCallOutcome> {
|
|
68
|
+
const result = await runSessionOp(async (client) => (await client.auth.login({ input })).login);
|
|
69
|
+
if (!result.ok) return { kind: 'error', code: result.error.code };
|
|
70
|
+
await replaceShopSession(result.data.token);
|
|
71
|
+
return { kind: 'ok' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sign the shopper out.
|
|
76
|
+
*
|
|
77
|
+
* Asks the shop API to end the session, then leaves this tab without one —
|
|
78
|
+
* the socket replaced by one that carries no session, both cookies cleared —
|
|
79
|
+
* WHATEVER the API answered: signing out is first a promise about this device,
|
|
80
|
+
* and a shopper on a shared computer who pressed the button must not stay
|
|
81
|
+
* signed in because the network blinked. The outcome reports the API's half:
|
|
82
|
+
* `ok` when the session is over (a session the API no longer holds included),
|
|
83
|
+
* the refusal's code when the API could not confirm it.
|
|
84
|
+
*/
|
|
85
|
+
export async function logoutCustomer(): Promise<AccountCallOutcome> {
|
|
86
|
+
const ended = await runSessionOp(async (client) => (await client.auth.logout()).logout);
|
|
87
|
+
await replaceShopSession(null);
|
|
88
|
+
if (ended.ok || isSessionGone(ended.error.code)) return { kind: 'ok' };
|
|
89
|
+
return { kind: 'error', code: ended.error.code };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Who this tab's session belongs to.
|
|
94
|
+
*
|
|
95
|
+
* A socket carrying no session is answered without a request — a visitor who
|
|
96
|
+
* has never shopped, or one who just signed out, belongs to nobody — and so is
|
|
97
|
+
* every page load that finds no mirror cookie, since the socket boots its
|
|
98
|
+
* session from it. Otherwise the shop API is asked, and an anonymous cart
|
|
99
|
+
* session answers `null` too.
|
|
100
|
+
*/
|
|
101
|
+
export async function findSignedInCustomer(): Promise<ActiveCustomerOutcome> {
|
|
102
|
+
const result = await runSessionOp(async (client) => {
|
|
103
|
+
if (client.getAuthToken() === null) return null;
|
|
104
|
+
const customer = (await client.customer.activeCustomer()).activeCustomer ?? null;
|
|
105
|
+
if (customer === null) return null;
|
|
106
|
+
return {
|
|
107
|
+
firstName: customer.firstName,
|
|
108
|
+
lastName: customer.lastName,
|
|
109
|
+
emailAddress: customer.emailAddress,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
if (!result.ok) return { kind: 'error', code: result.error.code };
|
|
113
|
+
return { kind: 'ok', customer: result.data };
|
|
114
|
+
}
|
|
@@ -1,24 +1,118 @@
|
|
|
1
1
|
import type { ExtractedError } from '@forgecart/sdk/shop';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Shopper-facing
|
|
4
|
+
* Shopper-facing copy for the typed error codes a storefront call can meet.
|
|
5
5
|
*
|
|
6
6
|
* The shop API's error contract ships CODES, never prose — `message` on an
|
|
7
7
|
* extracted error is the code string itself, and resolving human copy is the
|
|
8
8
|
* frontend's job (error-handling contract). A raw enum leaking into the UI is
|
|
9
9
|
* a defect (the address step rendered SHIPPING_ADDRESS_PHONE_REQUIRED
|
|
10
10
|
* verbatim, 2026-08-09).
|
|
11
|
+
*
|
|
12
|
+
* Two families live here. The checkout's address codes, and every refusal a
|
|
13
|
+
* customer ACCOUNT call can meet (#1093): signing in and out, registering, a
|
|
14
|
+
* verification or reset link, a password or e-mail change. The account family
|
|
15
|
+
* is swept against the shop's own `ErrorCode` enum by
|
|
16
|
+
* `tool/storefront-template-spec` — every member of its `Auth - Self-Service`
|
|
17
|
+
* and `Auth - Service` blocks, plus the sign-in refusal, the anonymous
|
|
18
|
+
* socket's refusal and the taken address — so a refusal added to the platform
|
|
19
|
+
* reddens that sweep until it has copy here. Before #1093 this map held the
|
|
20
|
+
* three address codes alone, and every refused sign-in rendered as the
|
|
21
|
+
* generic line: indistinguishable from the platform faults it was masking.
|
|
22
|
+
*
|
|
23
|
+
* `SHOP_API_UNREACHABLE` is not a platform code but this template's own
|
|
24
|
+
* (`lib/action-result.ts`): the answer when the API could not be asked at all,
|
|
25
|
+
* which is the one failure the generic line's "check your details" is wrong
|
|
26
|
+
* about.
|
|
27
|
+
*
|
|
28
|
+
* A Map rather than an object literal: a lookup keyed by an API-supplied
|
|
29
|
+
* string must not be able to land on `Object.prototype` (`constructor`,
|
|
30
|
+
* `toString`) and render a function as copy.
|
|
11
31
|
*/
|
|
12
|
-
const SHOPPER_MESSAGES:
|
|
13
|
-
SHIPPING_ADDRESS_PHONE_REQUIRED
|
|
14
|
-
SHIPPING_ADDRESS_COUNTRY_REQUIRED
|
|
15
|
-
SHIPPING_ADDRESS_COUNTRY_INVALID
|
|
16
|
-
|
|
32
|
+
const SHOPPER_MESSAGES: ReadonlyMap<string, string> = new Map([
|
|
33
|
+
['SHIPPING_ADDRESS_PHONE_REQUIRED', 'A phone number is required for delivery updates.'],
|
|
34
|
+
['SHIPPING_ADDRESS_COUNTRY_REQUIRED', 'Please select a country.'],
|
|
35
|
+
['SHIPPING_ADDRESS_COUNTRY_INVALID', 'We can’t ship to the selected country yet.'],
|
|
36
|
+
[
|
|
37
|
+
'INVALID_CREDENTIALS',
|
|
38
|
+
'That e-mail address and password do not match. Check both and try again.',
|
|
39
|
+
],
|
|
40
|
+
['UNAUTHORIZED', 'You are not signed in, or your session has ended. Please sign in again.'],
|
|
41
|
+
[
|
|
42
|
+
'EMAIL_ADDRESS_CONFLICT',
|
|
43
|
+
'That e-mail address already has an account. Sign in with it, or reset its password.',
|
|
44
|
+
],
|
|
45
|
+
[
|
|
46
|
+
'IDENTIFIER_ALREADY_EXISTS',
|
|
47
|
+
'That e-mail address already has an account. Sign in with it, or reset its password.',
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
'NOT_VERIFIED',
|
|
51
|
+
'This account is not confirmed yet. Open the link in the confirmation e-mail we sent you, then sign in.',
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
'VERIFICATION_TOKEN_INVALID',
|
|
55
|
+
'This confirmation link is not valid — it may have been used already. Ask for a new confirmation e-mail.',
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
'VERIFICATION_TOKEN_EXPIRED',
|
|
59
|
+
'This confirmation link has expired. Ask for a new confirmation e-mail and open the newest link.',
|
|
60
|
+
],
|
|
61
|
+
[
|
|
62
|
+
'PASSWORD_RESET_TOKEN_INVALID',
|
|
63
|
+
'This password reset link is not valid — it may have been used already. Ask for a new one.',
|
|
64
|
+
],
|
|
65
|
+
[
|
|
66
|
+
'PASSWORD_RESET_TOKEN_EXPIRED',
|
|
67
|
+
'This password reset link has expired. Ask for a new one and open the newest link.',
|
|
68
|
+
],
|
|
69
|
+
[
|
|
70
|
+
'PASSWORD_VALIDATION_FAILED',
|
|
71
|
+
'That password does not meet the store’s requirements. Please choose a different one.',
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'PASSWORD_CHANGE_FAILED',
|
|
75
|
+
'Your password could not be changed. Check your current password and try again.',
|
|
76
|
+
],
|
|
77
|
+
[
|
|
78
|
+
'IDENTIFIER_CHANGE_TOKEN_INVALID',
|
|
79
|
+
'This e-mail change link is not valid — it may have been used already. Ask for the change again.',
|
|
80
|
+
],
|
|
81
|
+
[
|
|
82
|
+
'IDENTIFIER_CHANGE_TOKEN_EXPIRED',
|
|
83
|
+
'This e-mail change link has expired. Ask for the change again and open the newest link.',
|
|
84
|
+
],
|
|
85
|
+
[
|
|
86
|
+
'CUSTOMER_NOT_FOUND',
|
|
87
|
+
'That account does not shop at this store. Sign in with the address you registered here.',
|
|
88
|
+
],
|
|
89
|
+
['SESSION_NOT_FOUND', 'Your session has ended. Please sign in again.'],
|
|
90
|
+
[
|
|
91
|
+
'SESSION_CACHE_READ_FAILED',
|
|
92
|
+
'We could not check your session just now. Please try again in a moment.',
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
'SESSION_CACHE_WRITE_FAILED',
|
|
96
|
+
'We could not save your session just now. Please try again in a moment.',
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
'SHOP_API_UNREACHABLE',
|
|
100
|
+
'We could not reach the store just now. Please check your connection and try again.',
|
|
101
|
+
],
|
|
102
|
+
]);
|
|
17
103
|
|
|
18
104
|
const FALLBACK_MESSAGE = 'Something went wrong. Please check your details and try again.';
|
|
19
105
|
|
|
20
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Human copy for a typed error — never undefined.
|
|
108
|
+
*
|
|
109
|
+
* A code this map has no copy for renders the generic line WITH the code
|
|
110
|
+
* appended in parentheses: an unmodelled refusal must stay distinguishable
|
|
111
|
+
* from a platform fault, by the shopper who reports it and by the merchant —
|
|
112
|
+
* or their agent — who reads the report. Only an error carrying no code at all
|
|
113
|
+
* renders the generic line bare.
|
|
114
|
+
*/
|
|
21
115
|
export function shopperMessage(error: Pick<ExtractedError, 'code'> | null | undefined): string {
|
|
22
116
|
if (!error?.code) return FALLBACK_MESSAGE;
|
|
23
|
-
return SHOPPER_MESSAGES
|
|
117
|
+
return SHOPPER_MESSAGES.get(error.code) ?? `${FALLBACK_MESSAGE} (${error.code})`;
|
|
24
118
|
}
|
|
@@ -31,6 +31,10 @@ import { getPublicOrigin } from './public-origin';
|
|
|
31
31
|
* no content of its own, identical for every visitor, so there is nothing for a
|
|
32
32
|
* search result to be about. One entry covers every language: this set is
|
|
33
33
|
* matched against the locale-STRIPPED path.
|
|
34
|
+
*
|
|
35
|
+
* `/account` (#1093) is here for the same funnel reason as `/register`: the
|
|
36
|
+
* server renders a skeleton, identical for every visitor, and everything the
|
|
37
|
+
* page shows is one shopper's own — sign-in form or greeting.
|
|
34
38
|
*/
|
|
35
39
|
export const NOINDEX_PATHS: readonly string[] = [
|
|
36
40
|
'/cart',
|
|
@@ -38,6 +42,7 @@ export const NOINDEX_PATHS: readonly string[] = [
|
|
|
38
42
|
'/verify',
|
|
39
43
|
'/reset-password',
|
|
40
44
|
'/register',
|
|
45
|
+
'/account',
|
|
41
46
|
];
|
|
42
47
|
|
|
43
48
|
/**
|
|
@@ -49,6 +49,8 @@ export const STATIC_ROUTES: readonly StaticRoute[] = [
|
|
|
49
49
|
// The register page (#1471). Suppressed for the same reason `/cart` is — a
|
|
50
50
|
// form with no content of its own — and listed for this table's stated one.
|
|
51
51
|
{ path: '/register', indexable: false },
|
|
52
|
+
// The account page (#1093): sign-in form or greeting, one shopper's own.
|
|
53
|
+
{ path: '/account', indexable: false },
|
|
52
54
|
];
|
|
53
55
|
|
|
54
56
|
/**
|
|
@@ -29,6 +29,10 @@ import { syncShopSession } from './session-actions';
|
|
|
29
29
|
* it from result extensions on the first mutation) is synced back into
|
|
30
30
|
* BOTH cookies via the `syncShopSession` server action — the next SSR
|
|
31
31
|
* pass sees the cart this socket built.
|
|
32
|
+
* - A sign-in or sign-out changes WHO the session is, and that never rides
|
|
33
|
+
* the capture: `replaceShopSession(token)` disposes the socket, opens a
|
|
34
|
+
* fresh one on the new session (or none) and writes both cookies before
|
|
35
|
+
* it resolves (`lib/account-session.ts` is its caller).
|
|
32
36
|
*
|
|
33
37
|
* Every operation returns the same `ActionResult` envelope the old Server
|
|
34
38
|
* Actions used, so calling components keep their error contract: typed
|
|
@@ -96,6 +100,24 @@ export function isSessionReady(): boolean {
|
|
|
96
100
|
return config !== null && typeof window !== 'undefined';
|
|
97
101
|
}
|
|
98
102
|
|
|
103
|
+
/** A client on the channel coordinates, carrying `token` — or no session when null. */
|
|
104
|
+
function openClient(coordinates: ShopSessionConfig, token: string | null): ForgeCartShopClient {
|
|
105
|
+
const opened = new ForgeCartShopClient({
|
|
106
|
+
endpoint: coordinates.apiUrl,
|
|
107
|
+
channelToken: coordinates.channelToken,
|
|
108
|
+
// Fixed at handshake — the cart, checkout and every error message on
|
|
109
|
+
// this socket answer in the language the URL resolved to.
|
|
110
|
+
languageCode: coordinates.languageCode,
|
|
111
|
+
// The visitor mirror is minted by the middleware before any page can
|
|
112
|
+
// mount the provider, so it is present on every real navigation; when
|
|
113
|
+
// it is somehow absent the client simply sends no visitor header and
|
|
114
|
+
// visitor-subject goals stay unstamped (never guess — #1079).
|
|
115
|
+
visitorId: readMirrorCookie(VISITOR_MIRROR_COOKIE) ?? undefined,
|
|
116
|
+
});
|
|
117
|
+
if (token) opened.setAuthToken(token);
|
|
118
|
+
return opened;
|
|
119
|
+
}
|
|
120
|
+
|
|
99
121
|
function ensureClient(): ForgeCartShopClient {
|
|
100
122
|
if (!config) {
|
|
101
123
|
throw new Error(
|
|
@@ -103,32 +125,63 @@ function ensureClient(): ForgeCartShopClient {
|
|
|
103
125
|
);
|
|
104
126
|
}
|
|
105
127
|
if (!client) {
|
|
106
|
-
client = new ForgeCartShopClient({
|
|
107
|
-
endpoint: config.apiUrl,
|
|
108
|
-
channelToken: config.channelToken,
|
|
109
|
-
// Fixed at handshake — the cart, checkout and every error message on
|
|
110
|
-
// this socket answer in the language the URL resolved to.
|
|
111
|
-
languageCode: config.languageCode,
|
|
112
|
-
// The visitor mirror is minted by the middleware before any page can
|
|
113
|
-
// mount the provider, so it is present on every real navigation; when
|
|
114
|
-
// it is somehow absent the client simply sends no visitor header and
|
|
115
|
-
// visitor-subject goals stay unstamped (never guess — #1079).
|
|
116
|
-
visitorId: readMirrorCookie(VISITOR_MIRROR_COOKIE) ?? undefined,
|
|
117
|
-
});
|
|
118
128
|
const existing = readMirrorSession();
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
syncedToken = existing;
|
|
122
|
-
}
|
|
129
|
+
client = openClient(config, existing);
|
|
130
|
+
if (existing) syncedToken = existing;
|
|
123
131
|
}
|
|
124
132
|
return client;
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Move this tab onto another session — the ONE path an identity change takes
|
|
137
|
+
* (#1093): a sign-in hands over the session it minted, a sign-out hands over
|
|
138
|
+
* `null`.
|
|
139
|
+
*
|
|
140
|
+
* The socket is REPLACED, never re-pointed. Its identity was fixed at its
|
|
141
|
+
* handshake, and a sign-in that left the live socket speaking for the session
|
|
142
|
+
* it opened with was refused UNAUTHORIZED on its very next operation — the
|
|
143
|
+
* greeting — and rendered as a failed sign-in (#1089). So the old client is
|
|
144
|
+
* disposed, which is terminal (nothing still in flight on it can complete
|
|
145
|
+
* under the wrong identity), and a fresh one carrying `token` takes its place;
|
|
146
|
+
* its first operation handshakes as the new session. The platform now rebinds
|
|
147
|
+
* a connection that signs in, and the SDK re-handshakes on `setAuthToken`; the
|
|
148
|
+
* replacement stays regardless, so which identity every later operation runs
|
|
149
|
+
* under is a fact of this module rather than of the platform and SDK versions
|
|
150
|
+
* a workspace happens to run.
|
|
151
|
+
*
|
|
152
|
+
* A sign-out also clears the token the SDK persists in the browser's storage.
|
|
153
|
+
* A fresh client restores that token when it is constructed, so without the
|
|
154
|
+
* clear the replacement would carry the very session it was meant to leave.
|
|
155
|
+
*
|
|
156
|
+
* Both cookies are written next, and AWAITED — unlike the capture in
|
|
157
|
+
* `runSessionOp` — so a caller that resolves after this has left the next
|
|
158
|
+
* server render and the next visit's socket on the same session as this tab.
|
|
159
|
+
* A failed write is absorbed exactly as the capture absorbs one: the socket
|
|
160
|
+
* is the authority for this tab, and a lost mirror costs the next visit its
|
|
161
|
+
* session, never this interaction.
|
|
162
|
+
*
|
|
163
|
+
* Before the cart provider has armed the singleton there is no socket to
|
|
164
|
+
* replace, and only the cookies move.
|
|
165
|
+
*/
|
|
166
|
+
export async function replaceShopSession(token: string | null): Promise<void> {
|
|
167
|
+
client?.dispose();
|
|
168
|
+
client = config ? openClient(config, token) : null;
|
|
169
|
+
if (token === null) client?.clearAuthToken();
|
|
170
|
+
syncedToken = token;
|
|
171
|
+
try {
|
|
172
|
+
await syncShopSession(token);
|
|
173
|
+
} catch {
|
|
174
|
+
// Absorbed by design — see above: the socket already carries `token`.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
127
178
|
/**
|
|
128
179
|
* Run one session operation on the shopper's socket and return the typed
|
|
129
180
|
* envelope. Session capture: when the operation minted or rotated the
|
|
130
181
|
* session, the new token is synced into the cookies fire-and-forget —
|
|
131
|
-
* a sync failure never breaks the shopper's interaction.
|
|
182
|
+
* a sync failure never breaks the shopper's interaction. An operation whose
|
|
183
|
+
* socket was replaced while it ran captures nothing: its session is no longer
|
|
184
|
+
* this tab's, and writing it would undo the replacement.
|
|
132
185
|
*/
|
|
133
186
|
export async function runSessionOp<T>(
|
|
134
187
|
op: (client: ForgeCartShopClient) => Promise<T>,
|
|
@@ -140,7 +193,7 @@ export async function runSessionOp<T>(
|
|
|
140
193
|
try {
|
|
141
194
|
const data = await op(live);
|
|
142
195
|
const token = live.getAuthToken();
|
|
143
|
-
if (token && token !== syncedToken) {
|
|
196
|
+
if (token && token !== syncedToken && live === client) {
|
|
144
197
|
syncedToken = token;
|
|
145
198
|
void syncShopSession(token).catch(() => undefined);
|
|
146
199
|
}
|
|
@@ -155,10 +155,49 @@ defers its first flush so a fast add-to-cart wins the first-touch race. The
|
|
|
155
155
|
mirror is what the browser's shop socket boots its auth from: written without
|
|
156
156
|
it, a relay-established session would never reach the client at all.
|
|
157
157
|
|
|
158
|
+
## Customer account
|
|
159
|
+
|
|
160
|
+
The scaffold ships the shopper's account surface, so a store starts with
|
|
161
|
+
sign-in, sign-out and registration working — extend it rather than rebuild it:
|
|
162
|
+
|
|
163
|
+
| Route | What it does |
|
|
164
|
+
| ----------- | ------------------------------------------------------------ |
|
|
165
|
+
| `/account` | signs a shopper in, greets them by name, signs them out |
|
|
166
|
+
| `/register` | creates an account; its confirmation mail links to `/verify` |
|
|
167
|
+
|
|
168
|
+
The header links `/account`, the footer `/register`. Five rules keep it
|
|
169
|
+
working — each one was learned from a store that broke it:
|
|
170
|
+
|
|
171
|
+
- **Account pages are static shells.**
|
|
172
|
+
[`src/app/account/page.tsx`](src/app/account/page.tsx) reads no session on
|
|
173
|
+
the server — no `next/headers`, no `connection()`, no session module. The
|
|
174
|
+
session lives on the shopper's own socket, in the `AccountPanel` client
|
|
175
|
+
island, which waits for the `CartProvider` to arm it (`useCart().hydrated`).
|
|
176
|
+
A session read on the server path throws during the render and 500s the page.
|
|
177
|
+
- **Sign in and out only through the session helpers.** `loginCustomer` and
|
|
178
|
+
`logoutCustomer` ([`src/lib/account-session.ts`](src/lib/account-session.ts))
|
|
179
|
+
replace the socket with one carrying the new session (or none) and write both
|
|
180
|
+
session cookies before they resolve: a socket left on the session it opened
|
|
181
|
+
with refuses the very next call. `findSignedInCustomer` says who is signed in.
|
|
182
|
+
- **Every refusal renders through `shopperMessage`**
|
|
183
|
+
([`src/lib/error-messages.ts`](src/lib/error-messages.ts)): it has copy for
|
|
184
|
+
every account refusal the shop API answers, and appends the raw code to its
|
|
185
|
+
generic line for any code it does not know. A new refusal's copy goes there,
|
|
186
|
+
once.
|
|
187
|
+
- **Registration requires the password.** The API treats it as optional, and an
|
|
188
|
+
account registered without one can never sign in.
|
|
189
|
+
- **`lib/shop-config` is server-only**, so no client component imports it, and
|
|
190
|
+
**the header links only routes the template ships**.
|
|
191
|
+
|
|
192
|
+
A store scaffolded before the surface shipped adopts it with `forgecart
|
|
193
|
+
refresh`: the account files it never had are rendered, the files it never
|
|
194
|
+
edited are replaced, and a file it edited — its `Header.tsx`, say — is reported
|
|
195
|
+
as a conflict and left as it is.
|
|
196
|
+
|
|
158
197
|
## Account links
|
|
159
198
|
|
|
160
199
|
Two routes exist because the PLATFORM links to them from the e-mails it sends
|
|
161
|
-
a shopper
|
|
200
|
+
a shopper:
|
|
162
201
|
|
|
163
202
|
| Route | Mail | What it does |
|
|
164
203
|
| ---------------------------- | ------------------- | ------------------------------------------------------------ |
|
|
@@ -194,11 +233,14 @@ src/
|
|
|
194
233
|
cart-actions.ts # cart Server Actions (incl. coupons)
|
|
195
234
|
checkout-actions.ts # checkout Server Actions
|
|
196
235
|
cart-context.tsx # client cart state + typed error channel
|
|
197
|
-
|
|
198
|
-
account
|
|
236
|
+
shop-session.ts # the shopper's own socket + session cookie sync
|
|
237
|
+
account-session.ts # register, sign-in, sign-out on that socket
|
|
238
|
+
account-actions.ts # password-reset + verification-resend Server Actions
|
|
239
|
+
account/ # pure account rules (token, states, ErrorCode map)
|
|
240
|
+
error-messages.ts # shopper copy for every typed error code
|
|
199
241
|
components/
|
|
200
242
|
ui/ # the vendored shadcn/ui components + their cn helper
|
|
201
|
-
Header.tsx # top nav
|
|
243
|
+
Header.tsx # top nav (Home / Products / Cart / Account) + cart count
|
|
202
244
|
ProductCard.tsx # product tile
|
|
203
245
|
ProductPurchase.tsx # variant picker + purchase options + add-to-cart
|
|
204
246
|
CartView.tsx # cart lines, coupons, totals, checkout CTA
|
|
@@ -208,7 +250,10 @@ src/
|
|
|
208
250
|
RatesStep.tsx # grouped radio-card rate selection
|
|
209
251
|
PaymentFormEmbed.tsx # origin-checked hosted-form host
|
|
210
252
|
account/
|
|
211
|
-
|
|
253
|
+
AccountPanel.tsx # sign-in form, greeting, sign-out (/account)
|
|
254
|
+
RegisterForm.tsx # create an account (/register)
|
|
255
|
+
VerificationResend.tsx # send the confirmation mail again
|
|
256
|
+
AccountMessage.tsx # the one outcome block the account pages use
|
|
212
257
|
ResetPasswordForm.tsx # set a new password from a reset token
|
|
213
258
|
RequestPasswordResetForm.tsx # ask for a reset link
|
|
214
259
|
app/
|
|
@@ -220,6 +265,8 @@ src/
|
|
|
220
265
|
products/ # grid + [slug] detail
|
|
221
266
|
cart/page.tsx # cart
|
|
222
267
|
checkout/page.tsx # the stepped checkout
|
|
268
|
+
account/page.tsx # sign-in + greeting: a static shell around AccountPanel
|
|
269
|
+
register/page.tsx # create an account
|
|
223
270
|
verify/page.tsx # e-mail verification link target
|
|
224
271
|
reset-password/page.tsx # password-reset link target + request form
|
|
225
272
|
```
|