@forgecart/cli 2.202608300703.0 → 2.202609190800.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/init.d.ts +21 -2
- package/dist/src/commands/init.js +18 -2
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +42 -4
- package/templates/storefront/next.config.js +29 -7
- package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
- package/templates/storefront/src/app/cart/page.tsx +14 -2
- package/templates/storefront/src/app/checkout/page.tsx +14 -2
- package/templates/storefront/src/app/layout.tsx +85 -22
- package/templates/storefront/src/app/page.tsx +63 -20
- package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
- package/templates/storefront/src/app/ping/route.ts +1 -1
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
- package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
- package/templates/storefront/src/app/products/page.tsx +41 -6
- package/templates/storefront/src/app/register/page.tsx +54 -0
- package/templates/storefront/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront/src/app/robots.ts +69 -0
- package/templates/storefront/src/app/sitemap.ts +106 -0
- package/templates/storefront/src/app/verify/page.tsx +155 -0
- package/templates/storefront/src/components/CartView.tsx +26 -7
- package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
- package/templates/storefront/src/components/Header.tsx +30 -10
- package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
- package/templates/storefront/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront/src/components/ProductCard.tsx +10 -4
- package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
- package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
- package/templates/storefront/src/lib/account/account-link.ts +76 -0
- package/templates/storefront/src/lib/account/register-state.ts +133 -0
- package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront/src/lib/account-actions.ts +76 -0
- package/templates/storefront/src/lib/account-session.ts +47 -0
- package/templates/storefront/src/lib/asset-alt.ts +34 -0
- package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront/src/lib/experiments.ts +1 -1
- package/templates/storefront/src/lib/forgecart.ts +300 -27
- package/templates/storefront/src/lib/format.ts +12 -14
- package/templates/storefront/src/lib/identify-forward.ts +152 -0
- package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront/src/lib/marketing-params.ts +213 -0
- package/templates/storefront/src/lib/money.ts +50 -0
- package/templates/storefront/src/lib/seo/alternates.ts +120 -0
- package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront/src/lib/seo/metadata.ts +323 -0
- package/templates/storefront/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
- package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront/src/lib/session-actions.ts +15 -8
- package/templates/storefront/src/lib/session-cookies.ts +98 -0
- package/templates/storefront/src/lib/shop-config.ts +9 -2
- package/templates/storefront/src/lib/shop-session.ts +20 -1
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +150 -8
- package/templates/storefront/src/seo/redirects.ts +44 -0
- package/templates/storefront/src/server/runner.ts +1 -2
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
/**
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Default shop API base, INCLUDING the mounted path. The API serves the shop
|
|
4
|
+
* GraphQL endpoint at `/shop-api` (`app/forgecart/src/main.ts`), and the SDK
|
|
5
|
+
* takes this value verbatim as its endpoint — it swaps the scheme for the
|
|
6
|
+
* websocket and appends nothing to the path — so a pathless origin scaffolds a
|
|
7
|
+
* storefront whose every read dials a socket nothing answers (#2102): the
|
|
8
|
+
* operation rides graphql-ws's whole retry ladder and the page hangs with
|
|
9
|
+
* nothing logged. Overridable via `--api-url`.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_SHOP_API_URL = "https://api.forgecart.com/shop-api";
|
|
4
12
|
/**
|
|
5
13
|
* Environment variable naming a dependency-warmed storefront template — a copy
|
|
6
14
|
* of the bundled template with `node_modules` (and the generated
|
|
@@ -40,6 +48,17 @@ export interface InitOptions {
|
|
|
40
48
|
* sides (deployed storefronts, loopback dev).
|
|
41
49
|
*/
|
|
42
50
|
browserApiUrl?: string;
|
|
51
|
+
/**
|
|
52
|
+
* The storefront's PUBLIC origin (`https://shop.example.com`) — the address
|
|
53
|
+
* shoppers and crawlers reach it at.
|
|
54
|
+
*
|
|
55
|
+
* Supplied for a published storefront and deliberately OMITTED for preview
|
|
56
|
+
* pods, whose hostnames are ephemeral. The template treats absence as "this
|
|
57
|
+
* deployment has no stable address to be canonical about" and serves every
|
|
58
|
+
* route noindex with `Disallow: /` (`lib/seo/public-origin.ts`), so omitting
|
|
59
|
+
* it is the safe state rather than an incomplete one.
|
|
60
|
+
*/
|
|
61
|
+
publicOrigin?: string;
|
|
43
62
|
/** Whether to also scaffold a NestJS application (planned for a later phase). */
|
|
44
63
|
application?: boolean;
|
|
45
64
|
/**
|
|
@@ -5,8 +5,16 @@ import { Command } from 'commander';
|
|
|
5
5
|
import { print } from '../output.js';
|
|
6
6
|
import { readPackageVersion } from '../version.js';
|
|
7
7
|
import { writeTemplateManifest } from './template-manifest.js';
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Default shop API base, INCLUDING the mounted path. The API serves the shop
|
|
10
|
+
* GraphQL endpoint at `/shop-api` (`app/forgecart/src/main.ts`), and the SDK
|
|
11
|
+
* takes this value verbatim as its endpoint — it swaps the scheme for the
|
|
12
|
+
* websocket and appends nothing to the path — so a pathless origin scaffolds a
|
|
13
|
+
* storefront whose every read dials a socket nothing answers (#2102): the
|
|
14
|
+
* operation rides graphql-ws's whole retry ladder and the page hangs with
|
|
15
|
+
* nothing logged. Overridable via `--api-url`.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_SHOP_API_URL = 'https://api.forgecart.com/shop-api';
|
|
10
18
|
/**
|
|
11
19
|
* Environment variable naming a dependency-warmed storefront template — a copy
|
|
12
20
|
* of the bundled template with `node_modules` (and the generated
|
|
@@ -145,6 +153,13 @@ function buildPlatformEnv(channelToken, options) {
|
|
|
145
153
|
if (secret) {
|
|
146
154
|
entries.set('FORGECART_ADMIN_SECRET', secret);
|
|
147
155
|
}
|
|
156
|
+
// Conditional for the same #1123 reason as the two above, and with a sharper
|
|
157
|
+
// consequence: a preview drive omits it, and stripping an origin an earlier
|
|
158
|
+
// drive wrote would silently de-index a live storefront.
|
|
159
|
+
const publicOrigin = options.publicOrigin?.trim();
|
|
160
|
+
if (publicOrigin) {
|
|
161
|
+
entries.set('FORGECART_PUBLIC_ORIGIN', publicOrigin);
|
|
162
|
+
}
|
|
148
163
|
return entries;
|
|
149
164
|
}
|
|
150
165
|
/**
|
|
@@ -304,6 +319,7 @@ export function createInitCommand() {
|
|
|
304
319
|
.option('--admin-secret <secret>', 'Channel admin secret for server-side admin operations (written to .env only)')
|
|
305
320
|
.option('--api-url <url>', 'Shop API base URL written to the storefront environment', DEFAULT_SHOP_API_URL)
|
|
306
321
|
.option('--browser-api-url <url>', 'Shop API URL for the shopper browser when it differs from --api-url (split-horizon pods; must be HTTPS so the session socket can use WSS)')
|
|
322
|
+
.option('--public-origin <url>', 'Public origin the storefront is served at (https://shop.example.com). Omit for preview deployments: without it every route is noindex and robots.txt disallows the whole tree')
|
|
307
323
|
.option('-a, --application', 'Also scaffold a NestJS application (TODO: next phase)')
|
|
308
324
|
.option('--config-only', 'Write only per-channel config into an existing pre-warmed storefront (skip the template copy)')
|
|
309
325
|
.action(async (name, options) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAE/D
|
|
1
|
+
{"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AACrC,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,wBAAwB,CAAC;AAE/D;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,oCAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,wBAAwB,CAAC;AAEnE;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,4BAA4B,CAAC;AA8EnE;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB;IACtC,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,OAAO,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,YAAY,CAAC,CAAC;AACpE,CAAC;AAED,kDAAkD;AAClD,KAAK,UAAU,WAAW,CAAC,IAAY;IACrC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;QAC9B,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,qDAAqD;AACrD,KAAK,UAAU,MAAM,CAAC,IAAY;IAChC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC,CAAC;QAC9B,OAAO,IAAI,CAAC,MAAM,EAAE,CAAC;IACvB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,sBAAsB,CAAC,YAAoB;IACxD,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAC1E,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,KAAK,UAAU,mBAAmB,CAAC,kBAA0B;IAC3D,MAAM,OAAO,GAAiB,EAAE,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAErF,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,0BAA0B,CAAC,CAAC;IAC1D,IAAI,CAAC,SAAS;QAAE,OAAO,OAAO,CAAC;IAC/B,IAAI,CAAC,CAAC,MAAM,WAAW,CAAC,SAAS,CAAC,CAAC;QAAE,OAAO,OAAO,CAAC;IAEpD,MAAM,oBAAoB,GAAG,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;IAC7D,IAAI,CAAC,CAAC,MAAM,WAAW,CAAC,oBAAoB,CAAC,CAAC;QAAE,OAAO,OAAO,CAAC;IAE/D,MAAM,cAAc,GAAG,MAAM,sBAAsB,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC,CAAC;IACrF,MAAM,eAAe,GAAG,MAAM,sBAAsB,CAClD,IAAI,CAAC,kBAAkB,EAAE,cAAc,CAAC,CACzC,CAAC;IACF,IAAI,CAAC,cAAc,IAAI,CAAC,eAAe,IAAI,cAAc,KAAK,eAAe,EAAE,CAAC;QAC9E,OAAO;YACL,MAAM,EAAE,6BAA6B;YACrC,WAAW,EAAE,kBAAkB;YAC/B,oBAAoB;SACrB,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC;AACtD,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,oBAAoB,CAAC,SAAiB,EAAE,IAAkB;IACvE,MAAM,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,sBAAsB,CAAC,CAAC;IAC3D,IAAI,IAAI,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC7B,MAAM,EAAE,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;QACtC,OAAO;IACT,CAAC;IAED,MAAM,MAAM,GAAG;QACb,UAAU,EAAE,IAAI,CAAC,WAAW;QAC5B,QAAQ,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACnC,CAAC;IACF,MAAM,SAAS,CAAC,UAAU,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC9E,CAAC;AAED,kDAAkD;AAClD,SAAS,WAAW,CAAC,YAAoB,EAAE,OAAoB;IAC7D,OAAO;QACL,OAAO,EAAE,KAAK;QACd,YAAY;QACZ,MAAM,EAAE,OAAO,CAAC,MAAM;QACtB,cAAc,EAAE,OAAO,CAAC,WAAW,IAAI,KAAK;QAC5C,cAAc,EAAE,GAAG;KACpB,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,gBAAgB,CAAC,YAAoB,EAAE,OAAoB;IAClE,MAAM,OAAO,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC1C,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,YAAY,CAAC,CAAC;IACrD,OAAO,CAAC,GAAG,CAAC,wBAAwB,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IACtD,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,EAAE,IAAI,EAAE,CAAC;IACpD,IAAI,aAAa,EAAE,CAAC;QAClB,OAAO,CAAC,GAAG,CAAC,gCAAgC,EAAE,aAAa,CAAC,CAAC;IAC/D,CAAC;IACD,MAAM,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAI,MAAM,EAAE,CAAC;QACX,OAAO,CAAC,GAAG,CAAC,wBAAwB,EAAE,MAAM,CAAC,CAAC;IAChD,CAAC;IACD,6EAA6E;IAC7E,4EAA4E;IAC5E,yDAAyD;IACzD,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,EAAE,IAAI,EAAE,CAAC;IAClD,IAAI,YAAY,EAAE,CAAC;QACjB,OAAO,CAAC,GAAG,CAAC,yBAAyB,EAAE,YAAY,CAAC,CAAC;IACvD,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,QAAQ,CAAC,QAAgB,EAAE,YAAoB,EAAE,OAAoB;IAC5E,MAAM,QAAQ,GAAG,gBAAgB,CAAC,YAAY,EAAE,OAAO,CAAC,CAAC;IACzD,MAAM,SAAS,GAAG,IAAI,GAAG,EAAU,CAAC;IACpC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,MAAM,IAAI,IAAI,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACxC,MAAM,GAAG,GAAG,mBAAmB,CAAC,IAAI,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC;QAC3D,IAAI,GAAG,KAAK,IAAI,EAAE,CAAC;YACjB,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACnB,CAAC;IACH,CAAC;IACD,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC;QAC7D,MAAM,CAAC,GAAG,EAAE,CAAC;IACf,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,QAAQ,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACxB,MAAM,CAAC,IAAI,CAAC,GAAG,GAAG,IAAI,KAAK,EAAE,CAAC,CAAC;QACjC,CAAC;IACH,CAAC;IACD,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAChB,OAAO,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC3B,CAAC;AAED;;;;GAIG;AACH,SAAS,mBAAmB,CAC1B,IAAY,EACZ,QAA6B,EAC7B,SAAsB;IAEtB,MAAM,GAAG,GAAG,qBAAqB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAClD,IAAI,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAC5C,IAAI,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IACpC,SAAS,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACnB,OAAO,GAAG,GAAG,IAAI,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,CAAC;AAC7C,CAAC;AAED,qFAAqF;AACrF,KAAK,UAAU,iBAAiB,CAAC,IAAY;IAC3C,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;QAClE,MAAM,KAAK,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,KAAK,UAAU,kBAAkB,CAC/B,SAAiB,EACjB,YAAoB,EACpB,OAAoB;IAEpB,MAAM,SAAS,GAAG,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IAChD,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5C,MAAM,SAAS,CACb,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,EAC9B,GAAG,IAAI,CAAC,SAAS,CAAC,WAAW,CAAC,YAAY,EAAE,OAAO,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAClE,MAAM,CACP,CAAC;IACF,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC;IACxC,MAAM,WAAW,GAAG,MAAM,iBAAiB,CAAC,OAAO,CAAC,CAAC;IACrD,MAAM,SAAS,CAAC,OAAO,EAAE,QAAQ,CAAC,WAAW,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,CAAC;AACjF,CAAC;AAED,0CAA0C;AAC1C,SAAS,cAAc,CAAC,SAAiB,EAAE,WAAoB,EAAE,aAAsB;IACrF,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,KAAK,CACH,aAAa;QACX,CAAC,CAAC,2FAA2F;QAC7F,CAAC,CAAC,qCAAqC,CAC1C,CAAC;IACF,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,KAAK,CAAC,aAAa,CAAC,CAAC;IACrB,KAAK,CAAC,QAAQ,SAAS,EAAE,CAAC,CAAC;IAC3B,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,KAAK,CAAC,eAAe,CAAC,CAAC;IACzB,CAAC;IACD,KAAK,CAAC,eAAe,CAAC,CAAC;IACvB,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,IAAI,WAAW,EAAE,CAAC;QAChB,KAAK,CACH,2EAA2E;YACzE,0BAA0B,CAC7B,CAAC;QACF,KAAK,CAAC,EAAE,CAAC,CAAC;IACZ,CAAC;AACH,CAAC;AAED,mFAAmF;AACnF,SAAS,eAAe,CAAC,SAAiB;IACxC,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,KAAK,CAAC,kFAAkF,CAAC,CAAC;IAC1F,KAAK,CAAC,EAAE,CAAC,CAAC;IACV,KAAK,CAAC,aAAa,CAAC,CAAC;IACrB,KAAK,CAAC,QAAQ,SAAS,EAAE,CAAC,CAAC;IAC3B,KAAK,CAAC,eAAe,CAAC,CAAC;IACvB,KAAK,CAAC,EAAE,CAAC,CAAC;AACZ,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,KAAK,UAAU,OAAO,CAAC,IAAwB,EAAE,OAAoB;IAC1E,MAAM,YAAY,GAAG,OAAO,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAI,CAAC,YAAY,EAAE,CAAC;QAClB,MAAM,IAAI,KAAK,CACb,4EAA4E,CAC7E,CAAC;IACJ,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;IAEtE,gFAAgF;IAChF,0EAA0E;IAC1E,+EAA+E;IAC/E,kFAAkF;IAClF,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;QACvB,IAAI,CAAC,CAAC,MAAM,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC,CAAC,EAAE,CAAC;YACrD,MAAM,IAAI,KAAK,CACb,oDAAoD,SAAS,2BAA2B,CACzF,CAAC;QACJ,CAAC;QACD,MAAM,kBAAkB,CAAC,SAAS,EAAE,YAAY,EAAE,OAAO,CAAC,CAAC;QAC3D,eAAe,CAAC,SAAS,CAAC,CAAC;QAC3B,OAAO;IACT,CAAC;IAED,MAAM,WAAW,GAAG,wBAAwB,EAAE,CAAC;IAC/C,IAAI,CAAC,CAAC,MAAM,WAAW,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC;QACtC,MAAM,IAAI,KAAK,CAAC,oCAAoC,WAAW,GAAG,CAAC,CAAC;IACtE,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,mBAAmB,CAAC,WAAW,CAAC,CAAC;IACpD,MAAM,KAAK,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5C,MAAM,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC3D,IAAI,IAAI,CAAC,MAAM,KAAK,6BAA6B,EAAE,CAAC;QAClD,MAAM,EAAE,CAAC,IAAI,CAAC,oBAAoB,EAAE,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,EAAE;YACnE,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC;IAED,MAAM,kBAAkB,CAAC,SAAS,EAAE,YAAY,EAAE,OAAO,CAAC,CAAC;IAC3D,MAAM,oBAAoB,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IAC5C,yEAAyE;IACzE,8DAA8D;IAC9D,MAAM,qBAAqB,CAAC,SAAS,EAAE,IAAI,CAAC,WAAW,EAAE,kBAAkB,EAAE,CAAC,CAAC;IAE/E,cAAc,CAAC,IAAI,IAAI,GAAG,EAAE,OAAO,CAAC,WAAW,IAAI,KAAK,EAAE,IAAI,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC;AACtF,CAAC;AAED,uCAAuC;AACvC,MAAM,UAAU,iBAAiB;IAC/B,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC;SACvB,WAAW,CAAC,yDAAyD,CAAC;SACtE,QAAQ,CAAC,QAAQ,EAAE,2DAA2D,CAAC;SAC/E,MAAM,CAAC,qBAAqB,EAAE,oCAAoC,CAAC;SACnE,MAAM,CACL,yBAAyB,EACzB,8EAA8E,CAC/E;SACA,MAAM,CACL,iBAAiB,EACjB,yDAAyD,EACzD,oBAAoB,CACrB;SACA,MAAM,CACL,yBAAyB,EACzB,2IAA2I,CAC5I;SACA,MAAM,CACL,uBAAuB,EACvB,+KAA+K,CAChL;SACA,MAAM,CAAC,mBAAmB,EAAE,uDAAuD,CAAC;SACpF,MAAM,CACL,eAAe,EACf,+FAA+F,CAChG;SACA,MAAM,CAAC,KAAK,EAAE,IAAwB,EAAE,OAAoB,EAAE,EAAE;QAC/D,MAAM,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;IAC/B,CAAC,CAAC,CAAC;AACP,CAAC"}
|
package/package.json
CHANGED
|
@@ -125,10 +125,40 @@ backstops.
|
|
|
125
125
|
## Sessions
|
|
126
126
|
|
|
127
127
|
The anonymous session is minted by the API on the first cart mutation and
|
|
128
|
-
persisted into the httpOnly `forgecart-session`
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
persisted into the session cookie PAIR — the httpOnly `forgecart-session` plus
|
|
129
|
+
its JS-readable `forgecart-session-client` mirror, whose one contract lives in
|
|
130
|
+
[`src/lib/session-cookies.ts`](src/lib/session-cookies.ts) — by the action
|
|
131
|
+
runner. The `/__fc/track` and `/__fc/identify` relays may **establish** that
|
|
132
|
+
pair for pure browsers but never replace an existing cookie, and the tracker
|
|
133
|
+
defers its first flush so a fast add-to-cart wins the first-touch race. The
|
|
134
|
+
mirror is what the browser's shop socket boots its auth from: written without
|
|
135
|
+
it, a relay-established session would never reach the client at all.
|
|
136
|
+
|
|
137
|
+
## Account links
|
|
138
|
+
|
|
139
|
+
Two routes exist because the PLATFORM links to them from the e-mails it sends
|
|
140
|
+
a shopper, not because anything in this storefront navigates there:
|
|
141
|
+
|
|
142
|
+
| Route | Mail | What it does |
|
|
143
|
+
| ---------------------------- | ------------------- | ------------------------------------------------------------ |
|
|
144
|
+
| `/verify?token=…` | e-mail verification | consumes the token on RENDER and reports the outcome |
|
|
145
|
+
| `/reset-password?token=…` | password reset | renders the new-password form; the SUBMIT consumes the token |
|
|
146
|
+
| `/reset-password` (no token) | — | asks for an address and has a new link sent |
|
|
147
|
+
|
|
148
|
+
**Do not rename or prefix them.** The path and the `?token=` parameter are the
|
|
149
|
+
contract with the mail templates, and a link already sitting in a shopper's
|
|
150
|
+
inbox cannot be re-issued. They are in the once-per-store set
|
|
151
|
+
([`src/lib/locale/grammar.ts`](src/lib/locale/grammar.ts)), so they never carry
|
|
152
|
+
a locale prefix, and in `NOINDEX_PATHS`
|
|
153
|
+
([`src/lib/seo/noindex.ts`](src/lib/seo/noindex.ts)), so they are `noindex`,
|
|
154
|
+
absent from the XML sitemap and `Disallow`ed in `robots.txt` — a crawler that
|
|
155
|
+
fetched one would spend a real shopper's one-shot token.
|
|
156
|
+
|
|
157
|
+
The COPY is yours to rewrite. The states behind it are not invented per page:
|
|
158
|
+
[`src/lib/account/`](src/lib/account/) maps the shop API's `ErrorCode`s onto a
|
|
159
|
+
small state set (verified / link-invalid / wrong-store / …) that both pages
|
|
160
|
+
render, and each surface stamps `data-fc-account-mode` + `data-fc-account-state`
|
|
161
|
+
onto the element it renders, so the state is readable from the served HTML.
|
|
132
162
|
|
|
133
163
|
## Project layout
|
|
134
164
|
|
|
@@ -142,6 +172,8 @@ src/
|
|
|
142
172
|
cart-actions.ts # cart Server Actions (incl. coupons)
|
|
143
173
|
checkout-actions.ts # checkout Server Actions
|
|
144
174
|
cart-context.tsx # client cart state + typed error channel
|
|
175
|
+
account-actions.ts # password-reset Server Actions
|
|
176
|
+
account/ # pure account-link rules (token, states, ErrorCode map)
|
|
145
177
|
components/
|
|
146
178
|
Header.tsx # top nav with live cart count
|
|
147
179
|
ProductCard.tsx # product tile
|
|
@@ -152,6 +184,10 @@ src/
|
|
|
152
184
|
AddressStep.tsx # autocomplete + countries + phone
|
|
153
185
|
RatesStep.tsx # grouped radio-card rate selection
|
|
154
186
|
PaymentFormEmbed.tsx # origin-checked hosted-form host
|
|
187
|
+
account/
|
|
188
|
+
AccountMessage.tsx # the one outcome block both account pages use
|
|
189
|
+
ResetPasswordForm.tsx # set a new password from a reset token
|
|
190
|
+
RequestPasswordResetForm.tsx # ask for a reset link
|
|
155
191
|
app/
|
|
156
192
|
layout.tsx # html shell + header/footer + CartProvider
|
|
157
193
|
error.tsx # route render backstop
|
|
@@ -160,6 +196,8 @@ src/
|
|
|
160
196
|
products/ # grid + [slug] detail
|
|
161
197
|
cart/page.tsx # cart
|
|
162
198
|
checkout/page.tsx # the stepped checkout
|
|
199
|
+
verify/page.tsx # e-mail verification link target
|
|
200
|
+
reset-password/page.tsx # password-reset link target + request form
|
|
163
201
|
```
|
|
164
202
|
|
|
165
203
|
## Development
|
|
@@ -37,13 +37,35 @@ const nextConfig = {
|
|
|
37
37
|
'dashboard.test.forgecart.com', // test dashboard origin that embeds the editor
|
|
38
38
|
'dashboard.forgecart.com', // prod dashboard origin that embeds the editor
|
|
39
39
|
],
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
40
|
+
// CACHE COMPONENTS: OFF, explicitly — and the explicitness is the point.
|
|
41
|
+
//
|
|
42
|
+
// The root layout reads the request locale (`x-forgecart-locale`) to set
|
|
43
|
+
// `<html lang>`, which is an attribute on the document root and therefore
|
|
44
|
+
// structurally outside every Suspense boundary. Under Cache Components that
|
|
45
|
+
// combination is not buildable: `headers()` returns a hanging promise, and a
|
|
46
|
+
// component aborted with no Suspense frame in its stack raises `blocking-route`
|
|
47
|
+
// → `StaticGenBailoutError`. The only escape Next offers is a `<Suspense>`
|
|
48
|
+
// ABOVE `<body>` — in its own words, "an explicit signal from the user that
|
|
49
|
+
// they acknowledge the empty shell".
|
|
50
|
+
//
|
|
51
|
+
// Taking that escape would cost the locale contract entirely. A real 308/404
|
|
52
|
+
// is assigned only when the render promise REJECTS (`renderToStream`'s catch);
|
|
53
|
+
// with a boundary above, React resolves as CLIENT_RENDERED instead, so
|
|
54
|
+
// `notFound()` / `permanentRedirect()` from the layout would emit a 200
|
|
55
|
+
// carrying a 404 page — a soft-404, indexed rather than dropped, which is the
|
|
56
|
+
// exact failure this storefront's locale routing exists to prevent.
|
|
57
|
+
//
|
|
58
|
+
// Nothing is given up by turning it off: the escape hatch yields an empty
|
|
59
|
+
// prelude on every route anyway (identical TTFB), and the template uses no
|
|
60
|
+
// `use cache`. The per-route Suspense boundaries below still stream their
|
|
61
|
+
// holes — that is core SSR, not a Cache Components feature.
|
|
62
|
+
//
|
|
63
|
+
// Written as an explicit `false` rather than omitted: `enforceExperimentalFeatures`
|
|
64
|
+
// re-enables an UNDEFINED value ("we do respect an explicit value in the user
|
|
65
|
+
// config"), so on a future 16.x under this package's caret range a deleted key
|
|
66
|
+
// would silently restore the failure above — in the merchant's scaffold, far
|
|
67
|
+
// from this repo's CI. An explicit false is immune.
|
|
68
|
+
cacheComponents: false,
|
|
47
69
|
experimental: {
|
|
48
70
|
// Persist Turbopack's dev compile artifacts to `.next` so a pod can serve
|
|
49
71
|
// a PRE-WARMED `.next` baked at build time instead of paying a cold first
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import type { NextRequest } from 'next/server';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
forwardMarketingIdentifiers,
|
|
6
|
+
type MarketingIdentifierInput,
|
|
7
|
+
} from '../../../lib/identify-forward';
|
|
8
|
+
import { CLICK_ID_VALUE_MAX_LENGTH } from '../../../lib/marketing-params';
|
|
9
|
+
import { SESSION_COOKIE, establishSessionCookies } from '../../../lib/session-cookies';
|
|
10
|
+
import { getUpstreamConfig, isObviousBot } from '../../../lib/track-forward';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Same-origin marketing-identity relay (#1596).
|
|
14
|
+
*
|
|
15
|
+
* A shopper arriving from an ad carries the platform's CLICK ID in the landing
|
|
16
|
+
* URL and nowhere else — no cookie, no header, and gone the moment they reach a
|
|
17
|
+
* second page. `lib/marketing-params.ts` reads it on the client, the landing
|
|
18
|
+
* page POSTs what it found here, and this handler forwards the batch to the
|
|
19
|
+
* ForgeCart shop API as one `setMarketingIdentifiers` mutation.
|
|
20
|
+
*
|
|
21
|
+
* The upstream hop itself (raw HTTP GraphQL with the channel token, the shopper
|
|
22
|
+
* session as Bearer, and every failure resolving to an empty outcome instead of
|
|
23
|
+
* a throw) lives in `src/lib/identify-forward.ts`. This route owns everything
|
|
24
|
+
* request-shaped: parsing untrusted JSON, per-identifier validation, the bot
|
|
25
|
+
* gate, session threading and cookie persistence.
|
|
26
|
+
*
|
|
27
|
+
* Sibling by construction: `../track/route.ts` relays EVENTS over the same
|
|
28
|
+
* transport, through the same gates, onto the same session cookie PAIR (both
|
|
29
|
+
* routes write it through `src/lib/session-cookies.ts`) — read the two
|
|
30
|
+
* together, and change them together. They stay separate routes because a
|
|
31
|
+
* click ID is not an event: it names the IDENTITY the events belong to, it is
|
|
32
|
+
* sent once per landing rather than once per batch, and the ad platforms read
|
|
33
|
+
* it back out of that identity months later when a conversion is reported.
|
|
34
|
+
*
|
|
35
|
+
* Inert guard: before `forgecart init` writes `.env` (the image-build prewarm)
|
|
36
|
+
* there is no shop to talk to, so every submitted key answers `rejected` with
|
|
37
|
+
* zero upstream calls and the server never crashes.
|
|
38
|
+
*
|
|
39
|
+
* Session capture: the shop API surfaces a freshly minted session in the
|
|
40
|
+
* response `extensions` exactly once. The relay may ESTABLISH the shopper
|
|
41
|
+
* identity — persisting that mint into BOTH homes of the session pair, the
|
|
42
|
+
* httpOnly `forgecart-session` cookie and its JS-readable mirror
|
|
43
|
+
* `forgecart-session-client` (`src/lib/session-cookies.ts`) — when the request
|
|
44
|
+
* arrived cookie-less, but it never REPLACES an existing cookie. The asymmetry
|
|
45
|
+
* is the sibling's, for the sibling's reason: an incoming cookie is either a
|
|
46
|
+
* live session owned by the cart path (clobbering it would vanish a
|
|
47
|
+
* just-created cart) or a stale one, whose replacement is the cart path's job.
|
|
48
|
+
*
|
|
49
|
+
* Writing both homes matters most HERE (#1733). On an ad landing this route
|
|
50
|
+
* runs before anything else — `ForgeTracker` awaits it before enqueuing the
|
|
51
|
+
* first page_view — so it is what establishes the session the click ID has just
|
|
52
|
+
* been attached to. Write the httpOnly half alone and the shopper's own socket
|
|
53
|
+
* boots unauthenticated, its first cart operation mints a RIVAL session, and
|
|
54
|
+
* the click ID stays on an identity the settled order never resolves to: the
|
|
55
|
+
* attribution this route exists to make possible, lost behind a 200. Presence
|
|
56
|
+
* is still read from the httpOnly copy alone — it is the authoritative one, and
|
|
57
|
+
* the mirror is client-writable.
|
|
58
|
+
*
|
|
59
|
+
* Folder name: `%5F%5Ffc` is URL-encoded `__fc` — the App Router treats
|
|
60
|
+
* `_`-prefixed folders as PRIVATE (excluded from routing), and the `%5F` escape
|
|
61
|
+
* is Next's documented way to serve a literal-underscore URL segment. A folder
|
|
62
|
+
* literally named `__fc` would silently 404.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The largest identifier list this route will look at.
|
|
67
|
+
*
|
|
68
|
+
* A landing page sends at most one entry per click ID it recognises: three
|
|
69
|
+
* today (`lib/marketing-params.ts`), six registered backend-side. Sixteen is
|
|
70
|
+
* headroom for every registration that could plausibly be added without
|
|
71
|
+
* admitting a list no client of ours would ever send — past it the caller is
|
|
72
|
+
* not our page, and the request is refused whole rather than trimmed, because
|
|
73
|
+
* silently keeping a prefix of somebody else's batch is the worse answer.
|
|
74
|
+
*/
|
|
75
|
+
const MAX_IDENTIFIERS = 16;
|
|
76
|
+
|
|
77
|
+
interface IdentifyResponseBody {
|
|
78
|
+
/** Identifier keys the backend recognised and stored. */
|
|
79
|
+
accepted: readonly string[];
|
|
80
|
+
/**
|
|
81
|
+
* Keys the backend does not know — a NORMAL outcome, never an error. The
|
|
82
|
+
* upstream partitions the submitted keys against its identifier registry,
|
|
83
|
+
* stores the ones it knows and reports the rest; nothing was rolled back and
|
|
84
|
+
* nothing may be retried. A key landing here means only that this storefront
|
|
85
|
+
* and the backend registry disagree about a spelling, which is a deployment
|
|
86
|
+
* fact worth being able to read.
|
|
87
|
+
*/
|
|
88
|
+
rejected: readonly string[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
92
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every answer this route gives is the same pair of key lists. */
|
|
96
|
+
function respond(accepted: readonly string[], rejected: readonly string[]): NextResponse {
|
|
97
|
+
const body: IdentifyResponseBody = { accepted, rejected };
|
|
98
|
+
return NextResponse.json(body);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Parse the request body into a bounded identifier array, or `null` when malformed. */
|
|
102
|
+
async function parseIdentifiers(request: NextRequest): Promise<unknown[] | null> {
|
|
103
|
+
let body: unknown;
|
|
104
|
+
try {
|
|
105
|
+
body = await request.json();
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (!isPlainObject(body) || !Array.isArray(body.identifiers)) return null;
|
|
110
|
+
if (body.identifiers.length === 0 || body.identifiers.length > MAX_IDENTIFIERS) return null;
|
|
111
|
+
return body.identifiers;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Validate one raw entry into a `MarketingIdentifierInput`, or `null` when it
|
|
116
|
+
* is not a usable pair. The route rebuilds the pair from untrusted JSON rather
|
|
117
|
+
* than trusting anything the client typed, so only these two fields cross.
|
|
118
|
+
*
|
|
119
|
+
* Both strings share the click-ID bound. The key is looked up in a registry
|
|
120
|
+
* whose entries are short words, so its cap is a garbage fence rather than a
|
|
121
|
+
* limit anyone can reach honestly — and a second number for the same purpose
|
|
122
|
+
* would be one more thing to keep in step, for no gain. The value's bound is
|
|
123
|
+
* the one that matters and is deliberately generous: Meta's `fbclid` routinely
|
|
124
|
+
* runs past 128 characters, and a click ID short by one character matches
|
|
125
|
+
* nothing at the platform that issued it.
|
|
126
|
+
*/
|
|
127
|
+
function validateIdentifier(raw: unknown): MarketingIdentifierInput | null {
|
|
128
|
+
if (!isPlainObject(raw)) return null;
|
|
129
|
+
const { key, value } = raw;
|
|
130
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > CLICK_ID_VALUE_MAX_LENGTH) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > CLICK_ID_VALUE_MAX_LENGTH) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return { key, value };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
140
|
+
const submitted = await parseIdentifiers(request);
|
|
141
|
+
if (!submitted) {
|
|
142
|
+
return NextResponse.json({ error: 'malformed identifiers' }, { status: 400 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validation runs BEFORE the gates here, unlike the event sibling: its reject
|
|
146
|
+
// shape is positional (one result per submitted item) and can be built from
|
|
147
|
+
// the raw batch, while this route answers with KEYS — which exist only once
|
|
148
|
+
// an entry has been proven to carry one.
|
|
149
|
+
const identifiers: MarketingIdentifierInput[] = [];
|
|
150
|
+
for (const entry of submitted) {
|
|
151
|
+
const identifier = validateIdentifier(entry);
|
|
152
|
+
// A malformed entry is DROPPED, never a 400: one bad pair must not cost a
|
|
153
|
+
// shopper the attribution the others carry. It is then absent from both
|
|
154
|
+
// answer lists rather than reported as rejected, so `rejected` keeps its
|
|
155
|
+
// single meaning — "the upstream does not know this key" — and can never be
|
|
156
|
+
// read as "you sent junk".
|
|
157
|
+
if (identifier) identifiers.push(identifier);
|
|
158
|
+
}
|
|
159
|
+
// Nothing survived validation: answer the empty verdict without an upstream
|
|
160
|
+
// call. Forwarding an empty list would mint a session for a request that has
|
|
161
|
+
// nothing to store against it.
|
|
162
|
+
if (identifiers.length === 0) return respond([], []);
|
|
163
|
+
|
|
164
|
+
const rejectAll = (): NextResponse => respond([], identifiers.map((identifier) => identifier.key));
|
|
165
|
+
|
|
166
|
+
// Inert before `forgecart init` writes `.env` (image-prewarm contract).
|
|
167
|
+
if (!getUpstreamConfig()) return rejectAll();
|
|
168
|
+
|
|
169
|
+
const userAgent = request.headers.get('user-agent') ?? '';
|
|
170
|
+
if (isObviousBot(userAgent)) return rejectAll();
|
|
171
|
+
|
|
172
|
+
const incomingSession = request.cookies.get(SESSION_COOKIE)?.value ?? null;
|
|
173
|
+
const forwardedFor = request.headers.get('x-forwarded-for');
|
|
174
|
+
// #1014: mirror the browser's low-entropy Client Hints trio upstream —
|
|
175
|
+
// Chromium sends them on every request; the shop API's device identification
|
|
176
|
+
// prefers them over the frozen UA.
|
|
177
|
+
const secChUa = request.headers.get('sec-ch-ua') ?? undefined;
|
|
178
|
+
const secChUaMobile = request.headers.get('sec-ch-ua-mobile') ?? undefined;
|
|
179
|
+
const secChUaPlatform = request.headers.get('sec-ch-ua-platform') ?? undefined;
|
|
180
|
+
|
|
181
|
+
// One call for the whole list: the mutation takes an array and the backend
|
|
182
|
+
// writes it against a single identity, so there is no per-item sequencing to
|
|
183
|
+
// get wrong here (the event relay loops only because `trackEvent` takes one
|
|
184
|
+
// event at a time).
|
|
185
|
+
const outcome = await forwardMarketingIdentifiers(identifiers, {
|
|
186
|
+
sessionToken: incomingSession,
|
|
187
|
+
userAgent,
|
|
188
|
+
forwardedFor,
|
|
189
|
+
secChUa,
|
|
190
|
+
secChUaMobile,
|
|
191
|
+
secChUaPlatform,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const response = respond(outcome.accepted, outcome.rejected);
|
|
195
|
+
// Establish-only persist: write the session ONLY when the request arrived
|
|
196
|
+
// without one, and then into BOTH of its homes — the httpOnly original the
|
|
197
|
+
// server reads and the mirror the shopper's own socket boots from. A mint
|
|
198
|
+
// against an EXISTING cookie is never persisted here — replacing a live
|
|
199
|
+
// session would vanish the cart it holds, and replacing a stale one belongs
|
|
200
|
+
// to the cart path (see the module docstring).
|
|
201
|
+
if (!incomingSession && outcome.sessionToken) {
|
|
202
|
+
establishSessionCookies(response, outcome.sessionToken);
|
|
203
|
+
}
|
|
204
|
+
return response;
|
|
205
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { NextResponse } from 'next/server';
|
|
2
2
|
import type { NextRequest } from 'next/server';
|
|
3
3
|
|
|
4
|
+
import { SESSION_COOKIE, establishSessionCookies } from '../../../lib/session-cookies';
|
|
4
5
|
import {
|
|
5
6
|
forwardTrackEvent,
|
|
6
7
|
getUpstreamConfig,
|
|
@@ -30,14 +31,25 @@ import {
|
|
|
30
31
|
* response `extensions` (`forgecart-auth-token`) exactly once; the first
|
|
31
32
|
* touched item captures it and the remaining items of the batch ride it as
|
|
32
33
|
* Bearer (one identity per batch). The relay may ESTABLISH the shopper
|
|
33
|
-
* identity — persisting
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
34
|
+
* identity — persisting that mint into BOTH homes of the session pair, the
|
|
35
|
+
* httpOnly `forgecart-session` cookie and its JS-readable mirror
|
|
36
|
+
* `forgecart-session-client`, through the contract `src/lib/session-cookies.ts`
|
|
37
|
+
* owns and `src/lib/session-actions.ts#syncShopSession` writes too — but it
|
|
38
|
+
* never REPLACES an existing cookie. The asymmetry is deliberate: an incoming
|
|
39
|
+
* cookie that the upstream re-mints against is either a live session owned by
|
|
40
|
+
* the cart path (clobbering it would vanish a just-created cart — the
|
|
41
|
+
* first-touch double-mint race) or a stale one, whose replacement is the cart
|
|
42
|
+
* path's job (the SDK adopts re-mints and `syncShopSession` persists them; the
|
|
43
|
+
* relay replacing it here would race those writers).
|
|
44
|
+
*
|
|
45
|
+
* Both homes or neither (#1733): the browser's shop socket boots its auth from
|
|
46
|
+
* the MIRROR (`lib/shop-session.ts`), so a session established into the httpOnly
|
|
47
|
+
* half alone never reaches the client at all — the first cart operation mints a
|
|
48
|
+
* RIVAL session, `syncShopSession` overwrites the pair with it, and the events
|
|
49
|
+
* this batch just recorded stay on a session no attribution read resolves to.
|
|
50
|
+
* Presence, in contrast, is read from the httpOnly copy ALONE: it is the
|
|
51
|
+
* authoritative one, the mirror is derived from it, and a client-writable cookie
|
|
52
|
+
* must not be able to decide whether this relay establishes at all.
|
|
41
53
|
*
|
|
42
54
|
* Residual window: two writers that BOTH start cookie-less inside the same
|
|
43
55
|
* in-flight window remain last-writer-wins. With the tracker's deferred first
|
|
@@ -55,10 +67,6 @@ import {
|
|
|
55
67
|
* A folder literally named `__fc` would silently 404.
|
|
56
68
|
*/
|
|
57
69
|
|
|
58
|
-
const SESSION_COOKIE = 'forgecart-session';
|
|
59
|
-
/** Mirrors `cart-actions.ts#persistSession` — the cookie is one shared identity. */
|
|
60
|
-
const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
|
61
|
-
|
|
62
70
|
/** The tracker flushes at ≤10 events; anything past this is not our client. */
|
|
63
71
|
const MAX_BATCH_SIZE = 50;
|
|
64
72
|
|
|
@@ -169,18 +177,13 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
|
|
169
177
|
}
|
|
170
178
|
|
|
171
179
|
const response = NextResponse.json({ results });
|
|
172
|
-
// Establish-only persist: write the
|
|
173
|
-
// without one
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
sameSite: 'lax',
|
|
181
|
-
path: '/',
|
|
182
|
-
maxAge: SESSION_COOKIE_MAX_AGE_SECONDS,
|
|
183
|
-
});
|
|
184
|
-
}
|
|
180
|
+
// Establish-only persist: write the session ONLY when the request arrived
|
|
181
|
+
// without one — and then into BOTH of its homes, which is all
|
|
182
|
+
// `establishSessionCookies` does. A mint against an EXISTING cookie is never
|
|
183
|
+
// persisted here — replacing a live session would vanish the cart it holds
|
|
184
|
+
// (first-touch double-mint race), and replacing a stale one belongs to the
|
|
185
|
+
// cart path, whose SDK capture + persist owns re-mints (see the module
|
|
186
|
+
// docstring).
|
|
187
|
+
if (!incomingSession && sessionToken) establishSessionCookies(response, sessionToken);
|
|
185
188
|
return response;
|
|
186
189
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Gated entirely on `NODE_ENV === 'development'`: a deployed `next start` storefront
|
|
12
12
|
* answers 404 here and forwards nothing — the whole beacon path ships only in the
|
|
13
|
-
* in-pod dev-server. Request access keeps it out of
|
|
13
|
+
* in-pod dev-server. Request access keeps it out of any static prerender so it
|
|
14
14
|
* behaves identically under `next dev` and a dev build.
|
|
15
15
|
*/
|
|
16
16
|
|
|
@@ -1,14 +1,25 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
1
2
|
import { connection } from 'next/server';
|
|
2
3
|
import { Suspense } from 'react';
|
|
3
4
|
|
|
4
5
|
import { CartView } from '../../components/CartView';
|
|
5
6
|
import { getChannelSellingPlanGroups } from '../../lib/forgecart';
|
|
7
|
+
import { getRequestLocale } from '../../lib/locale/request-binding';
|
|
8
|
+
import { routeRobots } from '../../lib/seo/noindex';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Never indexable: a cart is per-shopper state, identical in copy for everyone
|
|
12
|
+
* and meaningless to a visitor arriving from a search result.
|
|
13
|
+
*/
|
|
14
|
+
export function generateMetadata(): Metadata {
|
|
15
|
+
return { robots: routeRobots('/cart') };
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
/**
|
|
8
19
|
* Cart route. The shopper's cart itself lives on their OWN websocket
|
|
9
20
|
* (`CartView` hydrates it client-side); the server's only read here is the
|
|
10
21
|
* PUBLIC channel subscription plans off the anonymous channel singleton —
|
|
11
|
-
* streamed as a hole
|
|
22
|
+
* streamed as a hole while the shell renders.
|
|
12
23
|
*/
|
|
13
24
|
export default function CartPage() {
|
|
14
25
|
return (
|
|
@@ -20,11 +31,12 @@ export default function CartPage() {
|
|
|
20
31
|
|
|
21
32
|
async function CartWithPlans() {
|
|
22
33
|
await connection();
|
|
34
|
+
const { binding } = await getRequestLocale();
|
|
23
35
|
// Channel-wide subscription plans are public channel data — the one
|
|
24
36
|
// server-fetched input; the "Subscribe to your whole order" box renders
|
|
25
37
|
// only when there are any.
|
|
26
38
|
const channelGroups = await getChannelSellingPlanGroups();
|
|
27
|
-
return <CartView channelGroups={channelGroups} />;
|
|
39
|
+
return <CartView channelGroups={channelGroups} locale={binding} />;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
function CartRouteSkeleton() {
|
|
@@ -1,13 +1,24 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
1
2
|
import { connection } from 'next/server';
|
|
2
3
|
import { Suspense } from 'react';
|
|
3
4
|
|
|
4
5
|
import { CheckoutGate } from '../../components/checkout/CheckoutGate';
|
|
5
6
|
import { getAvailableCountries } from '../../lib/forgecart';
|
|
7
|
+
import { getRequestLocale } from '../../lib/locale/request-binding';
|
|
8
|
+
import { routeRobots } from '../../lib/seo/noindex';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Never indexable: checkout is a funnel step, not a destination, and every
|
|
12
|
+
* nested step inherits the decision through `NOINDEX_PATHS`.
|
|
13
|
+
*/
|
|
14
|
+
export function generateMetadata(): Metadata {
|
|
15
|
+
return { robots: routeRobots('/checkout') };
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
/**
|
|
8
19
|
* Checkout entry. The server's only job here is the PUBLIC prefetch — the
|
|
9
20
|
* channel's shipping countries off the anonymous channel singleton, streamed
|
|
10
|
-
* as a hole
|
|
21
|
+
* as a hole. The session work (the order itself, every
|
|
11
22
|
* checkout mutation) lives on the shopper's websocket: `CheckoutGate`
|
|
12
23
|
* hydrates the live cart client-side and mounts the flow, or shows the
|
|
13
24
|
* honest empty state.
|
|
@@ -25,8 +36,9 @@ export default function CheckoutPage() {
|
|
|
25
36
|
|
|
26
37
|
async function CheckoutWithCountries() {
|
|
27
38
|
await connection();
|
|
39
|
+
const { binding } = await getRequestLocale();
|
|
28
40
|
const countries = await getAvailableCountries();
|
|
29
|
-
return <CheckoutGate countries={countries} />;
|
|
41
|
+
return <CheckoutGate countries={countries} locale={binding} />;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
function CheckoutRouteSkeleton() {
|