@forgecart/cli 2.202610280649.0 → 2.202610280926.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-questions.d.ts +12 -11
- package/dist/src/commands/init-questions.js +15 -23
- package/dist/src/commands/init-questions.js.map +1 -1
- package/dist/src/commands/init.d.ts +13 -13
- package/dist/src/commands/init.js +75 -144
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/refresh.d.ts +3 -3
- package/dist/src/commands/refresh.js +4 -14
- package/dist/src/commands/refresh.js.map +1 -1
- package/dist/src/commands/template-manifest.d.ts +2 -5
- package/dist/src/commands/template-manifest.js +1 -1
- package/dist/src/commands/template-manifest.js.map +1 -1
- package/dist/src/commands/type/init.input.d.ts +1 -1
- package/dist/src/commands/type/init.types.d.ts +4 -4
- package/dist/src/template/template-set.d.ts +17 -27
- package/dist/src/template/template-set.js +25 -42
- package/dist/src/template/template-set.js.map +1 -1
- package/dist/src/template/template-unavailable-error.d.ts +3 -4
- package/dist/src/template/template-unavailable-error.js +3 -4
- package/dist/src/template/template-unavailable-error.js.map +1 -1
- package/dist/src/template/type/template-set.types.d.ts +14 -17
- package/dist/src/template/type/template-set.types.js +6 -1
- package/dist/src/template/type/template-set.types.js.map +1 -1
- package/dist/src/template/util/template-identity.util.d.ts +1 -3
- package/dist/src/template/util/template-identity.util.js +0 -5
- package/dist/src/template/util/template-identity.util.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront-blank/.forgecartignore +2 -0
- package/templates/storefront-blank/Procfile +1 -0
- package/templates/storefront-blank/README.md +86 -0
- package/templates/storefront-blank/SEO-MIGRATION.md +718 -0
- package/templates/storefront-blank/_gitignore +21 -0
- package/templates/storefront-blank/next.config.js +105 -0
- package/templates/storefront-blank/package.json +35 -0
- package/templates/storefront-blank/postcss.config.js +5 -0
- package/templates/storefront-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-blank/src/app/error.tsx +21 -0
- package/templates/storefront-blank/src/app/global-error.tsx +23 -0
- package/templates/storefront-blank/src/app/globals.css +110 -0
- package/templates/storefront-blank/src/app/layout.tsx +102 -0
- package/templates/storefront-blank/src/app/page.tsx +52 -0
- package/templates/storefront-blank/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront-blank/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-blank/src/app/ping/route.ts +21 -0
- package/templates/storefront-blank/src/app/robots.ts +69 -0
- package/templates/storefront-blank/src/app/sitemap.ts +107 -0
- package/templates/storefront-blank/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-blank/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-blank/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-blank/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-blank/src/instrumentation.ts +109 -0
- package/templates/storefront-blank/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-blank/src/lib/account/account-state.ts +152 -0
- package/templates/storefront-blank/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-blank/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-blank/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-blank/src/lib/account-actions.ts +76 -0
- package/templates/storefront-blank/src/lib/account-session.ts +114 -0
- package/templates/storefront-blank/src/lib/action-result.ts +30 -0
- package/templates/storefront-blank/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-blank/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-blank/src/lib/backend-client.ts +47 -0
- package/templates/storefront-blank/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-blank/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-blank/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-blank/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-blank/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-blank/src/lib/error-messages.ts +118 -0
- package/templates/storefront-blank/src/lib/experiments.ts +333 -0
- package/templates/storefront-blank/src/lib/forgecart.ts +464 -0
- package/templates/storefront-blank/src/lib/format.ts +89 -0
- package/templates/storefront-blank/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-blank/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-blank/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-blank/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-blank/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-blank/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-blank/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-blank/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-blank/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-blank/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-blank/src/lib/money.ts +50 -0
- package/templates/storefront-blank/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-blank/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-blank/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-blank/src/lib/seo/noindex.ts +223 -0
- package/templates/storefront-blank/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-blank/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-blank/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-blank/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-blank/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-blank/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-blank/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-blank/src/lib/seo/sitemap-entries.ts +310 -0
- package/templates/storefront-blank/src/lib/session-actions.ts +61 -0
- package/templates/storefront-blank/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-blank/src/lib/shop-config.ts +51 -0
- package/templates/storefront-blank/src/lib/shop-session.ts +204 -0
- package/templates/storefront-blank/src/lib/track-forward.ts +200 -0
- package/templates/storefront-blank/src/lib/uuid.ts +19 -0
- package/templates/storefront-blank/src/middleware.ts +379 -0
- package/templates/storefront-blank/src/seo/redirects.ts +44 -0
- package/templates/storefront-blank/src/seo/static-routes.ts +12 -0
- package/templates/storefront-blank/src/server/app.module.ts +18 -0
- package/templates/storefront-blank/src/server/backend-api.ts +21 -0
- package/templates/storefront-blank/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-blank/src/server/bootstrap.ts +122 -0
- package/templates/storefront-blank/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
- package/templates/storefront-blank/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-blank/src/server/runner.ts +90 -0
- package/templates/storefront-blank/src/server/types.ts +36 -0
- package/templates/storefront-blank/tsconfig.json +25 -0
- package/templates/storefront-blank-sdk-floor.json +1174 -0
- package/templates/storefront-shadcn-blank/.forgecartignore +2 -0
- package/templates/storefront-shadcn-blank/Procfile +1 -0
- package/templates/storefront-shadcn-blank/README.md +93 -0
- package/templates/storefront-shadcn-blank/SEO-MIGRATION.md +718 -0
- package/templates/storefront-shadcn-blank/_gitignore +21 -0
- package/templates/storefront-shadcn-blank/components.json +20 -0
- package/templates/storefront-shadcn-blank/next.config.js +105 -0
- package/templates/storefront-shadcn-blank/package.json +42 -0
- package/templates/storefront-shadcn-blank/postcss.config.js +5 -0
- package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn-blank/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn-blank/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn-blank/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn-blank/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn-blank/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn-blank/src/app/globals.css +156 -0
- package/templates/storefront-shadcn-blank/src/app/layout.tsx +102 -0
- package/templates/storefront-shadcn-blank/src/app/page.tsx +53 -0
- package/templates/storefront-shadcn-blank/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn-blank/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn-blank/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn-blank/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn-blank/src/app/sitemap.ts +107 -0
- package/templates/storefront-shadcn-blank/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn-blank/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn-blank/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn-blank/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn-blank/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn-blank/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn-blank/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn-blank/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn-blank/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn-blank/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn-blank/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn-blank/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn-blank/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn-blank/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn-blank/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn-blank/src/lib/account/account-state.ts +152 -0
- package/templates/storefront-shadcn-blank/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn-blank/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn-blank/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn-blank/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn-blank/src/lib/account-session.ts +114 -0
- package/templates/storefront-shadcn-blank/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn-blank/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn-blank/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn-blank/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn-blank/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn-blank/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn-blank/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn-blank/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn-blank/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn-blank/src/lib/error-messages.ts +118 -0
- package/templates/storefront-shadcn-blank/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn-blank/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn-blank/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn-blank/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn-blank/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn-blank/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn-blank/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/noindex.ts +223 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn-blank/src/lib/seo/sitemap-entries.ts +310 -0
- package/templates/storefront-shadcn-blank/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn-blank/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn-blank/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn-blank/src/lib/shop-session.ts +204 -0
- package/templates/storefront-shadcn-blank/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn-blank/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn-blank/src/middleware.ts +379 -0
- package/templates/storefront-shadcn-blank/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn-blank/src/seo/static-routes.ts +12 -0
- package/templates/storefront-shadcn-blank/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn-blank/src/server/backend-api.ts +21 -0
- package/templates/storefront-shadcn-blank/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn-blank/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn-blank/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart-client.factory.ts +73 -0
- package/templates/storefront-shadcn-blank/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn-blank/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn-blank/src/server/types.ts +36 -0
- package/templates/storefront-shadcn-blank/tsconfig.json +25 -0
- package/templates/storefront-shadcn-blank-sdk-floor.json +1174 -0
- package/templates/template-set.json +15 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mounts the ForgeCart visual-editor guest runtime. Renders nothing.
|
|
7
|
+
*
|
|
8
|
+
* The runtime stays dormant until activated: it registers a single `message`
|
|
9
|
+
* listener and posts a READY beacon, then waits for the dashboard host to
|
|
10
|
+
* drive a nonce-gated handshake over `postMessage` (the activation nonce
|
|
11
|
+
* arrives via the `#fcd=` URL fragment — there is no env switch). On a page
|
|
12
|
+
* that is not embedded in an iframe it never even loads.
|
|
13
|
+
*
|
|
14
|
+
* Production builds ship zero bytes of this: the literal `NODE_ENV` check is
|
|
15
|
+
* inlined by the bundler, so the effect body — including the dynamic import
|
|
16
|
+
* and its chunk — is dead-code-eliminated from `next build` output.
|
|
17
|
+
*/
|
|
18
|
+
export function ForgecartDesigner() {
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
// Top-level pages have no designer host; skip loading the chunk entirely.
|
|
24
|
+
if (window.parent === window) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
let cancelled = false;
|
|
28
|
+
let dispose: (() => void) | undefined;
|
|
29
|
+
const install = async () => {
|
|
30
|
+
const mod = await import('@forgecart/designer-runtime');
|
|
31
|
+
if (cancelled) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
dispose = mod.installDesignerRuntime();
|
|
35
|
+
};
|
|
36
|
+
void install();
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true;
|
|
39
|
+
dispose?.();
|
|
40
|
+
};
|
|
41
|
+
}, []);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import Link from 'next/link';
|
|
2
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
import { localizedPath } from '../lib/locale/localized-path';
|
|
5
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* An internal link that stays inside the request's locale cluster (#1346,
|
|
9
|
+
* W1-8 / S2).
|
|
10
|
+
*
|
|
11
|
+
* Every internal navigation goes through this instead of `next/link` directly.
|
|
12
|
+
* A German page whose links all point at the English cluster leaves every
|
|
13
|
+
* German page but the entry point unreachable by following links, so a crawler
|
|
14
|
+
* never discovers them and the locale is effectively unindexed.
|
|
15
|
+
*
|
|
16
|
+
* The locale arrives as a PROP and is never derived here. Under the middleware
|
|
17
|
+
* rewrite the browser's URL and the rendered route differ — `/de/products`
|
|
18
|
+
* renders the `/products` route — so `usePathname()` returns the REWRITTEN
|
|
19
|
+
* path, and a component that read the locale from it would emit English links
|
|
20
|
+
* on every German page while looking correct in review. Server components take
|
|
21
|
+
* the binding from `getRequestLocale()`; client components receive it from
|
|
22
|
+
* whichever server component renders them.
|
|
23
|
+
*
|
|
24
|
+
* `href` is the UNPREFIXED route path, always — the same string you would have
|
|
25
|
+
* written before the locale layer existed. Prefixing is this component's job.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// `locale` is omitted because `next/link` HAS a prop by that name — the Pages
|
|
29
|
+
// Router i18n feature, typed `string | false`. This storefront does its own
|
|
30
|
+
// locale routing (App Router, middleware rewrite), so that prop is never the
|
|
31
|
+
// one we want, and forwarding it would collide with the binding below: the
|
|
32
|
+
// production build fails with TS2430, which `next dev` never reports.
|
|
33
|
+
type ForwardedLinkProps = Omit<ComponentProps<typeof Link>, 'href' | 'children' | 'locale'>;
|
|
34
|
+
|
|
35
|
+
export interface LocaleLinkProps extends ForwardedLinkProps {
|
|
36
|
+
/** The unprefixed route path, e.g. `/products`. */
|
|
37
|
+
href: string;
|
|
38
|
+
/** The request's locale binding, from `getRequestLocale()`. */
|
|
39
|
+
locale: LocaleBinding;
|
|
40
|
+
children: ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function LocaleLink({ href, locale, children, ...rest }: LocaleLinkProps): ReactNode {
|
|
44
|
+
return (
|
|
45
|
+
<Link href={localizedPath(href, locale)} {...rest}>
|
|
46
|
+
{children}
|
|
47
|
+
</Link>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEV-ONLY server-error beacon via Next's `onRequestError` instrumentation hook.
|
|
3
|
+
*
|
|
4
|
+
* This is the PRIMARY server-side crash-detection surface for the in-pod dev-server:
|
|
5
|
+
* when a Server Component, a server action, or a route handler THROWS while rendering
|
|
6
|
+
* a request under `next dev`, Next invokes `onRequestError` with the structured error
|
|
7
|
+
* + request + routing context. The hook POSTs that detail SERVER-side, straight to the
|
|
8
|
+
* in-pod workspace-manager loopback receiver at
|
|
9
|
+
* `http://127.0.0.1:<FORGE_BEACON_PORT>/__forge_beacon`, which folds it into the
|
|
10
|
+
* supervisor's runtime state so the shop's recovery brain can originate a fix for a
|
|
11
|
+
* crash the `/ping` liveness probe cannot see (the dev-server process stays up and
|
|
12
|
+
* `/ping` stays 200, but `/` 500s per request).
|
|
13
|
+
*
|
|
14
|
+
* This complements the browser beacon (`ForgeErrorBeacon`): that one catches
|
|
15
|
+
* CLIENT-component / hydration throws that surface only in the browser; this one
|
|
16
|
+
* catches SERVER render throws that surface only in the dev-server. Both POST the
|
|
17
|
+
* same `/__forge_beacon` shape — the only difference is `source` (`'browser'` vs
|
|
18
|
+
* `'server'`).
|
|
19
|
+
*
|
|
20
|
+
* Gated entirely on `NODE_ENV === 'development'`: a deployed `next start` storefront
|
|
21
|
+
* registers no beacon (the literal check is inlined by the bundler, so the body is
|
|
22
|
+
* dead-code-eliminated from a production build) and the loopback receiver does not
|
|
23
|
+
* exist outside the pod. Next compiles `instrumentation.ts` for BOTH the Node and Edge
|
|
24
|
+
* runtimes; the loopback POST is a Node-only concern, so the hook also gates on the
|
|
25
|
+
* Node runtime — the Edge bundle then carries an inert no-op.
|
|
26
|
+
*
|
|
27
|
+
* The hook NEVER throws out of itself: a failed delivery is swallowed (the beacon is a
|
|
28
|
+
* best-effort backstop, never a hard dependency of the request), so a flapping receiver
|
|
29
|
+
* can never mask or replace the underlying request error Next is already reporting.
|
|
30
|
+
*
|
|
31
|
+
* The error's own `code` rides along verbatim — a fact, never a verdict: a request
|
|
32
|
+
* that races a recompile evaluates Turbopack's stub for a module that no longer parses
|
|
33
|
+
* and throws `code: 'MODULE_UNPARSABLE'`, and the receiver reads that code to leave a
|
|
34
|
+
* BUILD failure to the build detectors instead of folding it as a render crash.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Server-start hook: warm-boot the embedded NestJS backend (`src/server/`)
|
|
41
|
+
* so the first `sdk.backend` invocation doesn't pay the context creation.
|
|
42
|
+
*
|
|
43
|
+
* Warm-up ONLY — correctness never depends on it: the dispatch path's lazy
|
|
44
|
+
* `getBackend()` is what re-boots the context after dev-mode (HMR) edits,
|
|
45
|
+
* and a boot failure here is deliberately swallowed so a drifted api-map
|
|
46
|
+
* can never crash the server start — the first invocation (and the dev
|
|
47
|
+
* methods route) reports the same error as a typed envelope instead. The
|
|
48
|
+
* dynamic import keeps the Edge bundle inert (Nest is Node-only), and a
|
|
49
|
+
* no-env boot (image pre-warm) succeeds because clients are only built at
|
|
50
|
+
* invocation time.
|
|
51
|
+
*/
|
|
52
|
+
export async function register(): Promise<void> {
|
|
53
|
+
// POSITIVE if-block, never an early return: webpack's parser dead-code-
|
|
54
|
+
// eliminates a statically-false `if` BODY (NEXT_RUNTIME is DefinePlugin-
|
|
55
|
+
// substituted per bundle), but it does NOT track reachability past a
|
|
56
|
+
// `return` — with the guard inverted, the EDGE instrumentation bundle
|
|
57
|
+
// (compiled because middleware.ts exists) chases this import into
|
|
58
|
+
// @nestjs/*'s node-builtin requires and `next build --webpack` dies with
|
|
59
|
+
// five Module-not-found errors ('stream', 'os', 'perf_hooks'; every
|
|
60
|
+
// publish-button CI run since this import landed). Turbopack prunes the
|
|
61
|
+
// dead branch either way — only the webpack publish build sees the
|
|
62
|
+
// difference.
|
|
63
|
+
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
64
|
+
try {
|
|
65
|
+
const { getBackend } = await import('./server/bootstrap');
|
|
66
|
+
await getBackend();
|
|
67
|
+
} catch {
|
|
68
|
+
// Surfaced by the first sdk.backend invocation and /api/__backend/methods.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface OnRequestErrorRequest {
|
|
74
|
+
path: string;
|
|
75
|
+
method: string;
|
|
76
|
+
headers: { [key: string]: string | string[] | undefined };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface OnRequestErrorContext {
|
|
80
|
+
routerKind: string;
|
|
81
|
+
routePath: string;
|
|
82
|
+
routeType: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function onRequestError(
|
|
86
|
+
error: unknown,
|
|
87
|
+
request: OnRequestErrorRequest,
|
|
88
|
+
context: OnRequestErrorContext,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (process.env.NODE_ENV !== 'development' || process.env.NEXT_RUNTIME !== 'nodejs') {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const err = error as { message?: string; digest?: string; code?: unknown } | undefined;
|
|
95
|
+
const body = JSON.stringify({
|
|
96
|
+
message: typeof err?.message === 'string' ? err.message : String(error),
|
|
97
|
+
digest: typeof err?.digest === 'string' ? err.digest : undefined,
|
|
98
|
+
code: typeof err?.code === 'string' ? err.code : undefined,
|
|
99
|
+
route: request.path,
|
|
100
|
+
routeType: context.routeType,
|
|
101
|
+
source: 'server',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await fetch(`http://127.0.0.1:${BEACON_PORT}/__forge_beacon`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'content-type': 'application/json' },
|
|
107
|
+
body,
|
|
108
|
+
}).catch(() => undefined);
|
|
109
|
+
}
|
|
@@ -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,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
|
+
}
|
|
@@ -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
|
+
}
|