@forgecart/cli 2.202610141948.0 → 2.202610150140.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 (31) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront/README.md +52 -5
  3. package/templates/storefront/src/app/account/page.tsx +51 -0
  4. package/templates/storefront/src/app/layout.tsx +4 -5
  5. package/templates/storefront/src/app/verify/page.tsx +16 -4
  6. package/templates/storefront/src/components/Header.tsx +13 -1
  7. package/templates/storefront/src/components/account/AccountPanel.tsx +289 -0
  8. package/templates/storefront/src/components/account/RegisterForm.tsx +15 -66
  9. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +2 -2
  10. package/templates/storefront/src/components/account/VerificationResend.tsx +96 -0
  11. package/templates/storefront/src/lib/account/account-state.ts +152 -0
  12. package/templates/storefront/src/lib/account-session.ts +83 -16
  13. package/templates/storefront/src/lib/error-messages.ts +102 -8
  14. package/templates/storefront/src/lib/seo/noindex.ts +5 -0
  15. package/templates/storefront/src/lib/seo/sitemap-entries.ts +2 -0
  16. package/templates/storefront/src/lib/shop-session.ts +71 -18
  17. package/templates/storefront-shadcn/README.md +52 -5
  18. package/templates/storefront-shadcn/src/app/account/page.tsx +51 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +4 -5
  20. package/templates/storefront-shadcn/src/app/verify/page.tsx +16 -4
  21. package/templates/storefront-shadcn/src/components/Header.tsx +17 -1
  22. package/templates/storefront-shadcn/src/components/account/AccountPanel.tsx +290 -0
  23. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +19 -61
  24. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +2 -2
  25. package/templates/storefront-shadcn/src/components/account/VerificationResend.tsx +92 -0
  26. package/templates/storefront-shadcn/src/lib/account/account-state.ts +152 -0
  27. package/templates/storefront-shadcn/src/lib/account-session.ts +83 -16
  28. package/templates/storefront-shadcn/src/lib/error-messages.ts +102 -8
  29. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +5 -0
  30. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +2 -0
  31. package/templates/storefront-shadcn/src/lib/shop-session.ts +71 -18
@@ -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
- if (existing) {
120
- client.setAuthToken(existing);
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, not because anything in this storefront navigates there:
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
- account-actions.ts # password-reset Server Actions
198
- account/ # pure account-link rules (token, states, ErrorCode map)
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 with live cart count
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
- AccountMessage.tsx # the one outcome block both account pages use
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
  ```
@@ -0,0 +1,51 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ import { AccountPanel } from '../../components/account/AccountPanel';
4
+ import { getRequestLocale } from '../../lib/locale/request-binding';
5
+ import { routeMetadata } from '../../lib/seo/metadata';
6
+
7
+ /**
8
+ * `/account` — where a shopper signs in, is greeted, and signs out (#1093).
9
+ *
10
+ * A STATIC SHELL, and that is the law this file exists to keep: the server
11
+ * reads no session here — no cookie, no `connection()`, no session module. A
12
+ * storefront agent once read the session on the server path; the render threw
13
+ * `EmptyError: no elements in sequence`, and the 500 made the promote gate roll
14
+ * back the whole changeset. The session is the shopper's, on their own socket:
15
+ * `AccountPanel` asks it in the browser, and the server paints the skeleton it
16
+ * starts from. `tool/storefront-template-spec` holds this file to that.
17
+ *
18
+ * Locale-prefixable like `/register` — a shopper addresses it from a link on
19
+ * this storefront, never out of a mail — so `account` is absent from the
20
+ * grammar's unprefixed set and `/de/account` serves the German render.
21
+ */
22
+
23
+ const PAGE_TITLE = 'Your account';
24
+
25
+ /**
26
+ * Noindex via `NOINDEX_PATHS`, and no alternates — for the reasons `/register`'s
27
+ * metadata states: one shopper's own page has nothing for a search result to be
28
+ * about, and a page that declines indexing has no preferred address to claim.
29
+ */
30
+ export async function generateMetadata(): Promise<Metadata> {
31
+ const { binding, shopName, channelResolved } = await getRequestLocale();
32
+ return routeMetadata({
33
+ binding,
34
+ shopName,
35
+ channelResolved,
36
+ pathname: '/account',
37
+ pathsByLocale: {},
38
+ title: PAGE_TITLE,
39
+ });
40
+ }
41
+
42
+ export default async function AccountPage() {
43
+ const { binding } = await getRequestLocale();
44
+
45
+ return (
46
+ <div className="mx-auto max-w-xl space-y-6">
47
+ <h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
48
+ <AccountPanel locale={binding} />
49
+ </div>
50
+ );
51
+ }
@@ -149,11 +149,10 @@ export default async function RootLayout({ children }: { children: ReactNode })
149
149
  >
150
150
  Cart
151
151
  </LocaleLink>
152
- {/* The only affordance for `/register` (#1471). It sits in the
153
- footer rather than the header nav on purpose: the template
154
- ships no sign-in surface yet, and an account entry in the
155
- primary nav would advertise an account AREA that does not
156
- exist. A merchant who adds sign-in moves it up. */}
152
+ {/* The direct way to `/register` (#1471). The header's Account
153
+ link (#1093) is the account entry — sign-in, with
154
+ registration one link away — and this is the shortcut for
155
+ a shopper who already knows they are new. */}
157
156
  <LocaleLink
158
157
  href="/register"
159
158
  locale={binding}
@@ -70,39 +70,51 @@ export async function generateMetadata(): Promise<Metadata> {
70
70
  * first thing a rebrand rewrites, while the state set is the contract.
71
71
  */
72
72
 
73
- /** What each outcome tells the shopper, and where it sends them next. */
73
+ /**
74
+ * What each outcome tells the shopper, and where it sends them next. A verified
75
+ * account's next step is signing in (#1093), so that state links the account
76
+ * page; every other state sends the shopper back to the store.
77
+ */
74
78
  const VERIFY_COPY: Readonly<
75
- Record<VerifyState, { tone: AccountMessageTone; title: string; body: string; cta: string }>
79
+ Record<
80
+ VerifyState,
81
+ { tone: AccountMessageTone; title: string; body: string; cta: string; href: string }
82
+ >
76
83
  > = {
77
84
  verified: {
78
85
  tone: 'success',
79
86
  title: 'Your e-mail address is verified',
80
87
  body: 'Your account is ready — you can sign in with the address you registered.',
81
- cta: 'Start shopping',
88
+ cta: 'Sign in',
89
+ href: '/account',
82
90
  },
83
91
  'no-token': {
84
92
  tone: 'warning',
85
93
  title: 'This link is missing its verification token',
86
94
  body: 'Open the link straight from the e-mail we sent you. It carries a one-time token this page needs, and copying only part of the address leaves it behind.',
87
95
  cta: 'Go to the store',
96
+ href: '/',
88
97
  },
89
98
  'link-invalid': {
90
99
  tone: 'warning',
91
100
  title: 'This verification link is no longer valid',
92
101
  body: 'It has either been used already or it has expired. If your account is still unverified, request a new verification e-mail and open the newest link.',
93
102
  cta: 'Go to the store',
103
+ href: '/',
94
104
  },
95
105
  'wrong-store': {
96
106
  tone: 'warning',
97
107
  title: 'This link belongs to a different store',
98
108
  body: 'The account it verifies does not shop here. Open the link from the e-mail that store sent you — every store sends its own.',
99
109
  cta: 'Go to the store',
110
+ href: '/',
100
111
  },
101
112
  failed: {
102
113
  tone: 'error',
103
114
  title: 'We could not verify your e-mail address',
104
115
  body: 'Something went wrong on our side. Please open the link again in a few minutes — and if it has stopped working by then, request a new verification e-mail.',
105
116
  cta: 'Go to the store',
117
+ href: '/',
106
118
  },
107
119
  };
108
120
 
@@ -140,7 +152,7 @@ async function VerifyOutcome({ searchParams }: { searchParams: Promise<RouteSear
140
152
  <AccountMessage tone={copy.tone} title={copy.title}>
141
153
  {copy.body}
142
154
  </AccountMessage>
143
- <LocaleLink href="/" locale={binding} className={buttonVariants()}>
155
+ <LocaleLink href={copy.href} locale={binding} className={buttonVariants()}>
144
156
  {copy.cta}
145
157
  </LocaleLink>
146
158
  </div>
@@ -21,7 +21,16 @@ export interface HeaderProps {
21
21
  languageCodes: readonly string[];
22
22
  }
23
23
 
24
- /** Top navigation: Home / Products / Cart, with a live cart item count. */
24
+ /**
25
+ * Top navigation: Home / Products / Cart / Account, with a live cart item count.
26
+ *
27
+ * Every link names a route this template SHIPS (#1093): a link to a page the
28
+ * scaffold does not carry is a 404 for the shopper and a false lead for the
29
+ * agent extending the store, and `tool/storefront-template-spec` holds this
30
+ * file to it. The Account link is static — it never renders who is signed in —
31
+ * so nothing here depends on the session and no promote can trip a hydration
32
+ * mismatch on it; the account page itself says who is signed in.
33
+ */
25
34
  export function Header({ locale, languageCodes }: HeaderProps) {
26
35
  const { itemCount } = useCart();
27
36
 
@@ -65,6 +74,13 @@ export function Header({ locale, languageCodes }: HeaderProps) {
65
74
  Cart
66
75
  {mounted && itemCount > 0 && <Badge className="font-semibold">{itemCount}</Badge>}
67
76
  </LocaleLink>
77
+ <LocaleLink
78
+ href="/account"
79
+ locale={locale}
80
+ className={buttonVariants({ variant: 'ghost', size: 'sm' })}
81
+ >
82
+ Account
83
+ </LocaleLink>
68
84
  <LanguageSwitcher locale={locale} languageCodes={languageCodes} />
69
85
  </nav>
70
86
  </div>