@forgecart/cli 2.202608221935.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/cli.js +3 -21
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
- package/dist/src/commands/__test__/cli-harness.js +29 -0
- package/dist/src/commands/__test__/cli-harness.js.map +1 -0
- package/dist/src/commands/init.d.ts +28 -2
- package/dist/src/commands/init.js +100 -15
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/refresh.d.ts +40 -0
- package/dist/src/commands/refresh.js +147 -0
- package/dist/src/commands/refresh.js.map +1 -0
- package/dist/src/commands/template-manifest.d.ts +38 -0
- package/dist/src/commands/template-manifest.js +116 -0
- package/dist/src/commands/template-manifest.js.map +1 -0
- package/dist/src/version.d.ts +10 -0
- package/dist/src/version.js +25 -0
- package/dist/src/version.js.map +1 -0
- 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 +42 -5
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +196 -16
- package/templates/storefront/src/seo/redirects.ts +44 -0
- package/templates/storefront/src/server/runner.ts +1 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { connection } from 'next/server';
|
|
3
|
+
import { Suspense } from 'react';
|
|
4
|
+
|
|
5
|
+
import { AccountMessage, type AccountMessageTone } from '../../components/account/AccountMessage';
|
|
6
|
+
import { LocaleLink } from '../../components/LocaleLink';
|
|
7
|
+
import { readLinkToken, type AccountLinkOutcome } from '../../lib/account/account-link';
|
|
8
|
+
import { verifyState, type VerifyState } from '../../lib/account/verify-state';
|
|
9
|
+
import { verifyCustomerAccount } from '../../lib/forgecart';
|
|
10
|
+
import { getRequestLocale } from '../../lib/locale/request-binding';
|
|
11
|
+
import { routeMetadata } from '../../lib/seo/metadata';
|
|
12
|
+
import type { RouteSearchParams } from '../../lib/seo/noindex';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `/verify?token=…` — the page the platform's verification e-mail links to
|
|
16
|
+
* (#1472).
|
|
17
|
+
*
|
|
18
|
+
* Before this route existed, that link pointed at one platform-global host
|
|
19
|
+
* that served no such page, so every shopper who clicked it got a 404 and no
|
|
20
|
+
* account was ever verified from a mail. The link now resolves to the
|
|
21
|
+
* CHANNEL's own storefront, which is the only place that can both consume the
|
|
22
|
+
* token and tell the shopper, in the store's own voice, what happened to it.
|
|
23
|
+
*
|
|
24
|
+
* The token is consumed by the RENDER. That is what a mail link is: a GET
|
|
25
|
+
* whose entire purpose is to spend a one-shot credential, so there is no
|
|
26
|
+
* button to press first — pressing one would only add a step between the
|
|
27
|
+
* shopper and the outcome. It also means the second load of the same link is
|
|
28
|
+
* an expected state (`link-invalid`), not an error, which is why
|
|
29
|
+
* `verifyCustomerAccount` answers instead of throwing.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const PAGE_TITLE = 'Verify your e-mail';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Noindex via `NOINDEX_PATHS`, and NO alternates.
|
|
36
|
+
*
|
|
37
|
+
* `pathsByLocale` is deliberately empty rather than `staticPathsByLocale(…)`:
|
|
38
|
+
* `/verify` exists once per store (see `lib/locale/grammar.ts`), so there is no
|
|
39
|
+
* cluster of per-language addresses to claim — and building one would emit
|
|
40
|
+
* hreflang tags pointing every language at the same URL.
|
|
41
|
+
*
|
|
42
|
+
* The request's query is deliberately NOT passed either. The route rule
|
|
43
|
+
* already refuses indexing, so the only thing `searchParams` could add here is
|
|
44
|
+
* a shopper's one-shot token inside the metadata layer.
|
|
45
|
+
*/
|
|
46
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
47
|
+
const { binding, shopName, channelResolved } = await getRequestLocale();
|
|
48
|
+
return routeMetadata({
|
|
49
|
+
binding,
|
|
50
|
+
shopName,
|
|
51
|
+
channelResolved,
|
|
52
|
+
pathname: '/verify',
|
|
53
|
+
pathsByLocale: {},
|
|
54
|
+
title: PAGE_TITLE,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every account surface stamps `data-fc-account-mode` + `data-fc-account-state`
|
|
60
|
+
* on the element it renders (the same shape as the home page's
|
|
61
|
+
* `data-fc-variant`).
|
|
62
|
+
*
|
|
63
|
+
* The states of these pages are, by construction, the hardest thing in the
|
|
64
|
+
* template to observe: each is reached once, with a credential the visit
|
|
65
|
+
* destroys. The markers make the state machine READABLE from the served HTML,
|
|
66
|
+
* which is what `tool/storefront-verify` asserts against — deliberately
|
|
67
|
+
* instead of the copy, because the copy belongs to the merchant and is the
|
|
68
|
+
* first thing a rebrand rewrites, while the state set is the contract.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** What each outcome tells the shopper, and where it sends them next. */
|
|
72
|
+
const VERIFY_COPY: Readonly<
|
|
73
|
+
Record<VerifyState, { tone: AccountMessageTone; title: string; body: string; cta: string }>
|
|
74
|
+
> = {
|
|
75
|
+
verified: {
|
|
76
|
+
tone: 'success',
|
|
77
|
+
title: 'Your e-mail address is verified',
|
|
78
|
+
body: 'Your account is ready — you can sign in with the address you registered.',
|
|
79
|
+
cta: 'Start shopping',
|
|
80
|
+
},
|
|
81
|
+
'no-token': {
|
|
82
|
+
tone: 'warning',
|
|
83
|
+
title: 'This link is missing its verification token',
|
|
84
|
+
body: 'Open the link straight from the e-mail we sent you. It carries a one-time token this page needs, and copying only part of the address leaves it behind.',
|
|
85
|
+
cta: 'Go to the store',
|
|
86
|
+
},
|
|
87
|
+
'link-invalid': {
|
|
88
|
+
tone: 'warning',
|
|
89
|
+
title: 'This verification link is no longer valid',
|
|
90
|
+
body: 'It has either been used already or it has expired. If your account is still unverified, request a new verification e-mail and open the newest link.',
|
|
91
|
+
cta: 'Go to the store',
|
|
92
|
+
},
|
|
93
|
+
'wrong-store': {
|
|
94
|
+
tone: 'warning',
|
|
95
|
+
title: 'This link belongs to a different store',
|
|
96
|
+
body: 'The account it verifies does not shop here. Open the link from the e-mail that store sent you — every store sends its own.',
|
|
97
|
+
cta: 'Go to the store',
|
|
98
|
+
},
|
|
99
|
+
failed: {
|
|
100
|
+
tone: 'error',
|
|
101
|
+
title: 'We could not verify your e-mail address',
|
|
102
|
+
body: 'Something went wrong on our side. Please open the link again in a few minutes — and if it has stopped working by then, request a new verification e-mail.',
|
|
103
|
+
cta: 'Go to the store',
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export default function VerifyPage({ searchParams }: { searchParams: Promise<RouteSearchParams> }) {
|
|
108
|
+
return (
|
|
109
|
+
<div className="mx-auto max-w-xl space-y-6">
|
|
110
|
+
<h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
|
|
111
|
+
<Suspense fallback={<VerifyRouteSkeleton />}>
|
|
112
|
+
<VerifyOutcome searchParams={searchParams} />
|
|
113
|
+
</Suspense>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The request-time hole: the token is read and spent here, inside the Suspense
|
|
120
|
+
* boundary, so the shell paints while the API is asked.
|
|
121
|
+
*
|
|
122
|
+
* The `searchParams` promise is passed DOWN rather than awaited in the shell —
|
|
123
|
+
* awaiting it above would pull the token into the static part of the render for
|
|
124
|
+
* no benefit, since nothing above this component reads it.
|
|
125
|
+
*/
|
|
126
|
+
async function VerifyOutcome({ searchParams }: { searchParams: Promise<RouteSearchParams> }) {
|
|
127
|
+
await connection();
|
|
128
|
+
const [params, { binding }] = await Promise.all([searchParams, getRequestLocale()]);
|
|
129
|
+
|
|
130
|
+
const token = readLinkToken(params);
|
|
131
|
+
const outcome: AccountLinkOutcome =
|
|
132
|
+
token === null ? { kind: 'no-token' } : await verifyCustomerAccount(token);
|
|
133
|
+
const state = verifyState(outcome);
|
|
134
|
+
const copy = VERIFY_COPY[state];
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="space-y-6" data-fc-account-mode="verify" data-fc-account-state={state}>
|
|
138
|
+
<AccountMessage tone={copy.tone} title={copy.title}>
|
|
139
|
+
{copy.body}
|
|
140
|
+
</AccountMessage>
|
|
141
|
+
<LocaleLink href="/" locale={binding} className="btn btn-primary">
|
|
142
|
+
{copy.cta}
|
|
143
|
+
</LocaleLink>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function VerifyRouteSkeleton() {
|
|
149
|
+
return (
|
|
150
|
+
<div className="space-y-6" aria-busy="true" aria-label="Verifying your e-mail address">
|
|
151
|
+
<div className="skeleton h-20 w-full"></div>
|
|
152
|
+
<div className="skeleton h-12 w-44"></div>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
4
|
+
import { LocaleLink } from './LocaleLink';
|
|
4
5
|
import { useState } from 'react';
|
|
5
6
|
|
|
7
|
+
import { getAssetAlt } from '../lib/asset-alt';
|
|
6
8
|
import { useCart } from '../lib/cart-context';
|
|
7
9
|
import type { SellingPlan, SellingPlanGroup } from '../lib/forgecart';
|
|
8
10
|
import { formatPrice, getPlanCadenceLabel, getPlanSavingsLabel } from '../lib/format';
|
|
@@ -20,7 +22,14 @@ import { formatPrice, getPlanCadenceLabel, getPlanSavingsLabel } from '../lib/fo
|
|
|
20
22
|
* carry their own `sellingPlanId` (per-line subscriptions added from a product
|
|
21
23
|
* page) get a small "Subscription" label.
|
|
22
24
|
*/
|
|
23
|
-
export function CartView({
|
|
25
|
+
export function CartView({
|
|
26
|
+
channelGroups,
|
|
27
|
+
locale,
|
|
28
|
+
}: {
|
|
29
|
+
channelGroups: SellingPlanGroup[];
|
|
30
|
+
/** Threaded from the server page — a client component cannot read it. */
|
|
31
|
+
locale: LocaleBinding;
|
|
32
|
+
}) {
|
|
24
33
|
const {
|
|
25
34
|
cart,
|
|
26
35
|
itemCount,
|
|
@@ -59,9 +68,14 @@ export function CartView({ channelGroups }: { channelGroups: SellingPlanGroup[]
|
|
|
59
68
|
<p className="text-base-content/60">
|
|
60
69
|
Your cart is empty — it starts the moment you add your first item.
|
|
61
70
|
</p>
|
|
62
|
-
<
|
|
71
|
+
<LocaleLink
|
|
72
|
+
href="/products"
|
|
73
|
+
locale={locale}
|
|
74
|
+
data-fc-track="cart-browse-products"
|
|
75
|
+
className="btn btn-primary"
|
|
76
|
+
>
|
|
63
77
|
Browse products
|
|
64
|
-
</
|
|
78
|
+
</LocaleLink>
|
|
65
79
|
</div>
|
|
66
80
|
);
|
|
67
81
|
}
|
|
@@ -86,7 +100,7 @@ export function CartView({ channelGroups }: { channelGroups: SellingPlanGroup[]
|
|
|
86
100
|
// eslint-disable-next-line @next/next/no-img-element
|
|
87
101
|
<img
|
|
88
102
|
src={line.featuredAsset.preview}
|
|
89
|
-
alt={line.
|
|
103
|
+
alt={getAssetAlt(line.featuredAsset)}
|
|
90
104
|
className="h-full w-full object-cover"
|
|
91
105
|
/>
|
|
92
106
|
) : null}
|
|
@@ -279,9 +293,14 @@ export function CartView({ channelGroups }: { channelGroups: SellingPlanGroup[]
|
|
|
279
293
|
</div>
|
|
280
294
|
</div>
|
|
281
295
|
|
|
282
|
-
<
|
|
296
|
+
<LocaleLink
|
|
297
|
+
href="/checkout"
|
|
298
|
+
locale={locale}
|
|
299
|
+
data-fc-track="cart-checkout"
|
|
300
|
+
className="btn btn-primary w-full"
|
|
301
|
+
>
|
|
283
302
|
Checkout
|
|
284
|
-
</
|
|
303
|
+
</LocaleLink>
|
|
285
304
|
</div>
|
|
286
305
|
</aside>
|
|
287
306
|
</div>
|
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
import { usePathname } from 'next/navigation';
|
|
4
4
|
import { useEffect } from 'react';
|
|
5
5
|
|
|
6
|
+
import {
|
|
7
|
+
type MarketingIdentifier,
|
|
8
|
+
parseMarketingParams,
|
|
9
|
+
type UtmParams,
|
|
10
|
+
} from '../lib/marketing-params';
|
|
6
11
|
import { mintUuid } from '../lib/uuid';
|
|
7
12
|
|
|
8
13
|
/**
|
|
@@ -29,6 +34,14 @@ import { mintUuid } from '../lib/uuid';
|
|
|
29
34
|
* - `heartbeat` every {@link HEARTBEAT_MS} while the tab is visible (plus
|
|
30
35
|
* one on refocus) — liveness plumbing only, excluded from aggregates.
|
|
31
36
|
*
|
|
37
|
+
* What a landing additionally does: a view whose URL carries marketing
|
|
38
|
+
* parameters reads them once (`lib/marketing-params`). The click IDs go to
|
|
39
|
+
* {@link IDENTIFY_ENDPOINT} and that request is AWAITED before the view is
|
|
40
|
+
* enqueued, so the identity the relay mints carries them instead of the two
|
|
41
|
+
* racing; the campaign tags are held for the rest of the visit and ride every
|
|
42
|
+
* later event. A URL without any of them does nothing at all — no request, no
|
|
43
|
+
* stored state, no change to the events below.
|
|
44
|
+
*
|
|
32
45
|
* Delivery: events buffer and flush as one batch at {@link FLUSH_MAX_EVENTS}
|
|
33
46
|
* events or after an age timer via `fetch(keepalive)` — the FIRST batch waits
|
|
34
47
|
* {@link ESTABLISHMENT_GRACE_MS}, every later batch {@link FLUSH_INTERVAL_MS}
|
|
@@ -80,6 +93,8 @@ const SUPPRESS_MS = 300;
|
|
|
80
93
|
*/
|
|
81
94
|
const ENGAGEMENT_MIN_DWELL_MS = 250;
|
|
82
95
|
const TRACK_ENDPOINT = '/__fc/track';
|
|
96
|
+
/** Landing-only: where a captured click ID is attached to the shopper. */
|
|
97
|
+
const IDENTIFY_ENDPOINT = '/__fc/identify';
|
|
83
98
|
|
|
84
99
|
/** One buffered event — the relay forwards these fields to `trackEvent`. */
|
|
85
100
|
interface TrackedEvent {
|
|
@@ -87,6 +102,21 @@ interface TrackedEvent {
|
|
|
87
102
|
eventId: string;
|
|
88
103
|
occurredAt: string;
|
|
89
104
|
properties?: Record<string, unknown>;
|
|
105
|
+
/**
|
|
106
|
+
* The campaign this visit arrived on, repeated on every event of the visit.
|
|
107
|
+
*
|
|
108
|
+
* Spelled out rather than reused from the parser's `UtmParams` because the
|
|
109
|
+
* five fields are a CONTRACT with the relay's allowlist, not a shape that
|
|
110
|
+
* should follow the parser if it ever grows a sixth tag. An absent tag is an
|
|
111
|
+
* absent KEY here, never `''`: the backend stores a present empty string as a
|
|
112
|
+
* real ClickHouse dimension, where it can no longer be told apart from a
|
|
113
|
+
* campaign that genuinely has no medium.
|
|
114
|
+
*/
|
|
115
|
+
utmSource?: string;
|
|
116
|
+
utmMedium?: string;
|
|
117
|
+
utmCampaign?: string;
|
|
118
|
+
utmTerm?: string;
|
|
119
|
+
utmContent?: string;
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
// Module scope on purpose: the buffer, timers and suppressors must survive
|
|
@@ -100,6 +130,23 @@ const lastClickAtBySlug = new Map<string, number>();
|
|
|
100
130
|
let lastPageView: { path: string; at: number } | null = null;
|
|
101
131
|
let previousPathname: string | null = null;
|
|
102
132
|
let lastHeartbeatAt = 0;
|
|
133
|
+
/**
|
|
134
|
+
* The campaign tags of the landing that started this visit. Module scope for
|
|
135
|
+
* the same reason as the buffer — StrictMode remounts the component and a ref
|
|
136
|
+
* would reset — and because there is nowhere else to read them from: an
|
|
137
|
+
* internal navigation carries no query string.
|
|
138
|
+
*/
|
|
139
|
+
let capturedUtm: UtmParams = {};
|
|
140
|
+
/**
|
|
141
|
+
* The click IDs already sent to {@link IDENTIFY_ENDPOINT}, as `key=value&…`.
|
|
142
|
+
*
|
|
143
|
+
* Module scope so StrictMode's mount → unmount → mount sends ONE identify for
|
|
144
|
+
* one landing. A signature rather than a boolean because "once per captured
|
|
145
|
+
* landing" is not "once per visit": a shopper who clicks a second ad back into
|
|
146
|
+
* the store brings a different click ID that must still be delivered, while a
|
|
147
|
+
* revisit of the same landing URL must not send the same one twice.
|
|
148
|
+
*/
|
|
149
|
+
let identifiedClickIds: string | null = null;
|
|
103
150
|
|
|
104
151
|
/**
|
|
105
152
|
* Consent extension point. Tracking currently needs no opt-in, so this is a
|
|
@@ -127,6 +174,9 @@ function enqueue(eventType: string, properties?: Record<string, unknown>): void
|
|
|
127
174
|
eventType,
|
|
128
175
|
eventId: mintEventId(),
|
|
129
176
|
occurredAt: new Date().toISOString(),
|
|
177
|
+
// Spreading an empty object adds no keys, so an organic visit emits exactly
|
|
178
|
+
// the event shape it did before any of this existed.
|
|
179
|
+
...capturedUtm,
|
|
130
180
|
};
|
|
131
181
|
if (properties) event.properties = properties;
|
|
132
182
|
buffer.push(event);
|
|
@@ -167,6 +217,61 @@ function flush(): void {
|
|
|
167
217
|
}).catch(() => undefined);
|
|
168
218
|
}
|
|
169
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Read this URL's marketing parameters and act on what it carries.
|
|
222
|
+
*
|
|
223
|
+
* Resolves once the click IDs have been delivered — the caller awaits it so the
|
|
224
|
+
* identify request and the first tracked batch cannot race. Does nothing
|
|
225
|
+
* whatsoever for a URL that carries none, which is the overwhelmingly common
|
|
226
|
+
* case and the reason the parser returns a union.
|
|
227
|
+
*/
|
|
228
|
+
async function captureLanding(search: string): Promise<void> {
|
|
229
|
+
// Consent is checked HERE as well as in `enqueue` because the identify
|
|
230
|
+
// request is not a buffered event: it leaves on its own, carrying a click ID
|
|
231
|
+
// that identifies the visitor more directly than anything in the buffer, so
|
|
232
|
+
// a gate that only guards `enqueue` would let it past a visitor who declined.
|
|
233
|
+
if (!hasTrackingConsent()) return;
|
|
234
|
+
const marketing = parseMarketingParams(search);
|
|
235
|
+
if (marketing.kind === 'none') return;
|
|
236
|
+
|
|
237
|
+
// Merge, never clear — the same semantics `middleware.ts` uses for the
|
|
238
|
+
// `fc-exp` cookie. Only a URL that carries tags updates them, so an internal
|
|
239
|
+
// navigation keeps the campaign the visitor arrived on, and a second ad
|
|
240
|
+
// landing legitimately overwrites the tags it brings.
|
|
241
|
+
capturedUtm = { ...capturedUtm, ...marketing.utm };
|
|
242
|
+
|
|
243
|
+
if (marketing.identifiers.length === 0) return;
|
|
244
|
+
const signature = signClickIds(marketing.identifiers);
|
|
245
|
+
if (signature === identifiedClickIds) return;
|
|
246
|
+
// Recorded BEFORE the await: StrictMode's second mount runs while this
|
|
247
|
+
// request is still in flight and must find the landing already claimed.
|
|
248
|
+
identifiedClickIds = signature;
|
|
249
|
+
await fetch(IDENTIFY_ENDPOINT, {
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: { 'content-type': 'application/json' },
|
|
252
|
+
body: JSON.stringify({ identifiers: marketing.identifiers }),
|
|
253
|
+
keepalive: true,
|
|
254
|
+
}).catch(() => undefined); // same swallow as flush(): analytics never breaks the storefront
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The click IDs of one landing as a comparable string, in parser order. */
|
|
258
|
+
function signClickIds(identifiers: readonly MarketingIdentifier[]): string {
|
|
259
|
+
return identifiers.map(({ key, value }) => `${key}=${value}`).join('&');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Capture the landing, THEN record the view.
|
|
264
|
+
*
|
|
265
|
+
* The order is the whole point. The first request of a visit is what mints the
|
|
266
|
+
* shopper session, so sending the identify and the page_view in parallel would
|
|
267
|
+
* mint one identity per request and split the visit in two — the click ID on
|
|
268
|
+
* one, every tracked event on the other, and no attribution joining them.
|
|
269
|
+
*/
|
|
270
|
+
async function emitPageView(properties: Record<string, unknown>): Promise<void> {
|
|
271
|
+
await captureLanding(window.location.search);
|
|
272
|
+
enqueue('page_view', properties);
|
|
273
|
+
}
|
|
274
|
+
|
|
170
275
|
/**
|
|
171
276
|
* `pagehide` delivery: `sendBeacon` — the only flush that uses it, for its
|
|
172
277
|
* survive-the-unload queueing guarantee. Falls back to keepalive fetch where
|
|
@@ -212,7 +317,9 @@ export function ForgeTracker({ enabled }: { enabled: boolean }) {
|
|
|
212
317
|
properties.referrer = document.referrer;
|
|
213
318
|
}
|
|
214
319
|
previousPathname = pathname;
|
|
215
|
-
|
|
320
|
+
// The suppressor above is already stamped, so the double mount StrictMode
|
|
321
|
+
// performs while this is in flight still emits exactly one view.
|
|
322
|
+
void emitPageView(properties);
|
|
216
323
|
}, [enabled, pathname]);
|
|
217
324
|
|
|
218
325
|
// cta_click — one delegated capture-phase listener — and the pagehide flush.
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import Link from 'next/link';
|
|
4
3
|
import { useEffect, useState } from 'react';
|
|
5
4
|
|
|
5
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
6
6
|
import { useCart } from '../lib/cart-context';
|
|
7
|
+
import { LanguageSwitcher } from './LanguageSwitcher';
|
|
8
|
+
import { LocaleLink } from './LocaleLink';
|
|
9
|
+
|
|
10
|
+
export interface HeaderProps {
|
|
11
|
+
/**
|
|
12
|
+
* The request's locale, threaded down from the layout. A client component
|
|
13
|
+
* cannot read it: `usePathname()` returns the REWRITTEN path under the
|
|
14
|
+
* locale rewrite, so deriving it here would emit English links on every
|
|
15
|
+
* German page.
|
|
16
|
+
*/
|
|
17
|
+
locale: LocaleBinding;
|
|
18
|
+
/** The channel's languages, for the switcher. */
|
|
19
|
+
languageCodes: readonly string[];
|
|
20
|
+
}
|
|
7
21
|
|
|
8
22
|
/** Top navigation: Home / Products / Cart, with a live cart item count. */
|
|
9
|
-
export function Header() {
|
|
23
|
+
export function Header({ locale, languageCodes }: HeaderProps) {
|
|
10
24
|
const { itemCount } = useCart();
|
|
11
25
|
|
|
12
26
|
// The count is seeded from the server's `initialCart`, but a cookie set after
|
|
@@ -22,22 +36,28 @@ export function Header() {
|
|
|
22
36
|
return (
|
|
23
37
|
<header className="sticky top-0 z-30 border-b border-base-300 bg-base-100">
|
|
24
38
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
|
25
|
-
<
|
|
39
|
+
<LocaleLink href="/" locale={locale} className="text-lg font-bold tracking-tight">
|
|
26
40
|
Storefront
|
|
27
|
-
</
|
|
41
|
+
</LocaleLink>
|
|
28
42
|
<nav aria-label="Store" className="flex items-center gap-1 text-sm font-medium">
|
|
29
|
-
<
|
|
43
|
+
<LocaleLink href="/" locale={locale} className="btn btn-ghost btn-sm">
|
|
30
44
|
Home
|
|
31
|
-
</
|
|
32
|
-
<
|
|
45
|
+
</LocaleLink>
|
|
46
|
+
<LocaleLink href="/products" locale={locale} className="btn btn-ghost btn-sm">
|
|
33
47
|
Products
|
|
34
|
-
</
|
|
35
|
-
<
|
|
48
|
+
</LocaleLink>
|
|
49
|
+
<LocaleLink
|
|
50
|
+
href="/cart"
|
|
51
|
+
locale={locale}
|
|
52
|
+
data-fc-track="header-cart"
|
|
53
|
+
className="btn btn-ghost btn-sm"
|
|
54
|
+
>
|
|
36
55
|
Cart
|
|
37
56
|
{mounted && itemCount > 0 && (
|
|
38
57
|
<span className="badge badge-primary badge-sm font-semibold">{itemCount}</span>
|
|
39
58
|
)}
|
|
40
|
-
</
|
|
59
|
+
</LocaleLink>
|
|
60
|
+
<LanguageSwitcher locale={locale} languageCodes={languageCodes} />
|
|
41
61
|
</nav>
|
|
42
62
|
</div>
|
|
43
63
|
</header>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { usePathname } from 'next/navigation';
|
|
4
|
+
|
|
5
|
+
import { switchedLocalePath } from '../lib/locale/localized-path';
|
|
6
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The language switcher (#1346, W1-8 / S2).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately plain `<a>` elements rather than `<LocaleLink>` / `next/link`,
|
|
12
|
+
* for two reasons that point the same way:
|
|
13
|
+
*
|
|
14
|
+
* 1. **A locale switch must produce a NEW DOCUMENT.** Under the middleware
|
|
15
|
+
* rewrite `/products` and `/de/products` render the SAME route, so a
|
|
16
|
+
* client-side navigation between them changes no route segment and the
|
|
17
|
+
* shared root layout is not re-run — `<html lang>` would keep the language
|
|
18
|
+
* the shopper just left. A document load re-enters the middleware and
|
|
19
|
+
* re-resolves the locale from the URL, which makes the new `lang`, the new
|
|
20
|
+
* `Content-Language` and the new session language unconditional.
|
|
21
|
+
* 2. **These anchors are the discovery bridge between clusters.** Every other
|
|
22
|
+
* link stays inside its own locale by construction, so without a real
|
|
23
|
+
* crawlable `<a>` here nothing on the English site would ever point at the
|
|
24
|
+
* German one. (The head-level `<link rel="alternate" hreflang>` annotation
|
|
25
|
+
* that formalises the relationship is #1347's; `hrefLang` on the anchor is
|
|
26
|
+
* just the honest description of where this particular link goes.)
|
|
27
|
+
*
|
|
28
|
+
* **Why `usePathname()` here, when `LocaleLink` forbids it.** What that hook
|
|
29
|
+
* cannot give you is the LOCALE: the rewrite strips the prefix, so the path it
|
|
30
|
+
* returns names the route, not the language — which is why the binding arrives
|
|
31
|
+
* as a prop. The PATH is the one thing it reports correctly, and it does so on
|
|
32
|
+
* both branches: on a prefixed URL it returns the rewritten (unprefixed) route,
|
|
33
|
+
* and on an unprefixed URL it returns the path itself. Either form is safe
|
|
34
|
+
* input, because the grammar strips any prefix before applying the target's —
|
|
35
|
+
* `de → fr` yields `/fr/...`, never `/fr/de/...`, whichever form it saw.
|
|
36
|
+
*
|
|
37
|
+
* The alternative — carrying the path in a middleware request header — was
|
|
38
|
+
* rejected: the header is only authored on the REWRITE branch, so it is absent
|
|
39
|
+
* on exactly the unprefixed pages this switcher must work on, and populating it
|
|
40
|
+
* there would force `NextResponse.next({request})` (a full copy of the inbound
|
|
41
|
+
* header block) onto every request that currently takes the fast path.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
export interface LanguageSwitcherProps {
|
|
45
|
+
/** The request's locale binding, from the layout. Never derived here. */
|
|
46
|
+
locale: LocaleBinding;
|
|
47
|
+
/** The channel's languages, from `getRequestLocale()`. */
|
|
48
|
+
languageCodes: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function LanguageSwitcher({ locale, languageCodes }: LanguageSwitcherProps) {
|
|
52
|
+
const pathname = usePathname();
|
|
53
|
+
|
|
54
|
+
// A one-language channel has nothing to switch between, and the grammar
|
|
55
|
+
// never serves it a prefix — a single dead entry would be noise.
|
|
56
|
+
if (languageCodes.length < 2) return null;
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<nav aria-label="Language" className="flex items-center gap-1">
|
|
60
|
+
{languageCodes.map((code) => {
|
|
61
|
+
// The current language is not a link to itself: a self-link is a crawl
|
|
62
|
+
// trap and reads as an available action when it is not one.
|
|
63
|
+
if (code === locale.locale) {
|
|
64
|
+
return (
|
|
65
|
+
<span
|
|
66
|
+
key={code}
|
|
67
|
+
aria-current="true"
|
|
68
|
+
className="btn btn-ghost btn-xs btn-active font-semibold uppercase"
|
|
69
|
+
>
|
|
70
|
+
{code}
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<a
|
|
76
|
+
key={code}
|
|
77
|
+
href={switchedLocalePath(code, pathname, locale)}
|
|
78
|
+
hrefLang={code}
|
|
79
|
+
data-fc-track="language-switch"
|
|
80
|
+
className="btn btn-ghost btn-xs uppercase"
|
|
81
|
+
>
|
|
82
|
+
{code}
|
|
83
|
+
</a>
|
|
84
|
+
);
|
|
85
|
+
})}
|
|
86
|
+
</nav>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
2
|
+
import { LocaleLink } from './LocaleLink';
|
|
2
3
|
|
|
4
|
+
import { getAssetAlt } from '../lib/asset-alt';
|
|
3
5
|
import type { Product } from '../lib/forgecart';
|
|
4
6
|
import { formatPrice, getStartingPrice } from '../lib/format';
|
|
5
7
|
|
|
@@ -10,16 +12,20 @@ import { formatPrice, getStartingPrice } from '../lib/format';
|
|
|
10
12
|
*/
|
|
11
13
|
export function ProductCard({
|
|
12
14
|
product,
|
|
15
|
+
locale,
|
|
13
16
|
emphasis = false,
|
|
14
17
|
}: {
|
|
15
18
|
product: Product;
|
|
19
|
+
/** The request's locale binding — a card's link must stay in its cluster. */
|
|
20
|
+
locale: LocaleBinding;
|
|
16
21
|
emphasis?: boolean;
|
|
17
22
|
}) {
|
|
18
23
|
const startingPrice = getStartingPrice(product);
|
|
19
24
|
|
|
20
25
|
return (
|
|
21
|
-
<
|
|
26
|
+
<LocaleLink
|
|
22
27
|
href={`/products/${product.slug}`}
|
|
28
|
+
locale={locale}
|
|
23
29
|
data-fc-track="product-card"
|
|
24
30
|
className="card card-border group h-full bg-base-100 transition-shadow hover:shadow-md"
|
|
25
31
|
>
|
|
@@ -29,7 +35,7 @@ export function ProductCard({
|
|
|
29
35
|
// eslint-disable-next-line @next/next/no-img-element
|
|
30
36
|
<img
|
|
31
37
|
src={product.featuredAsset.preview}
|
|
32
|
-
alt={product.
|
|
38
|
+
alt={getAssetAlt(product.featuredAsset)}
|
|
33
39
|
loading="lazy"
|
|
34
40
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
35
41
|
/>
|
|
@@ -47,6 +53,6 @@ export function ProductCard({
|
|
|
47
53
|
<p className="text-sm text-base-content/60">From {formatPrice(startingPrice)}</p>
|
|
48
54
|
)}
|
|
49
55
|
</div>
|
|
50
|
-
</
|
|
56
|
+
</LocaleLink>
|
|
51
57
|
);
|
|
52
58
|
}
|