@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,18 +1,104 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { notFound } from 'next/navigation';
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound, permanentRedirect } from 'next/navigation';
|
|
3
3
|
import { connection } from 'next/server';
|
|
4
|
-
import { Suspense } from 'react';
|
|
4
|
+
import { Suspense, cache } from 'react';
|
|
5
5
|
|
|
6
6
|
import { ProductPurchase } from '../../../components/ProductPurchase';
|
|
7
|
+
import { LocaleLink } from '../../../components/LocaleLink';
|
|
8
|
+
import { getAssetAlt } from '../../../lib/asset-alt';
|
|
7
9
|
import {
|
|
8
10
|
getProductBySlug,
|
|
9
11
|
getSellingPlanGroupsForVariant,
|
|
12
|
+
type ProductWithSeo,
|
|
10
13
|
type SellingPlanGroup,
|
|
11
14
|
} from '../../../lib/forgecart';
|
|
15
|
+
import type { LocaleBinding } from '../../../lib/locale/localized-path';
|
|
16
|
+
import { getRequestLocale } from '../../../lib/locale/request-binding';
|
|
17
|
+
import { buildAlternates } from '../../../lib/seo/alternates';
|
|
18
|
+
import {
|
|
19
|
+
breadcrumbJsonLd,
|
|
20
|
+
productJsonLd,
|
|
21
|
+
serializeJsonLd,
|
|
22
|
+
type JsonLdNode,
|
|
23
|
+
} from '../../../lib/seo/json-ld';
|
|
24
|
+
import { entityMetadata, routeMetadata } from '../../../lib/seo/metadata';
|
|
25
|
+
import { routeRobots, type RouteSearchParams } from '../../../lib/seo/noindex';
|
|
26
|
+
import { getPublicOrigin } from '../../../lib/seo/public-origin';
|
|
27
|
+
import { productPathsByLocale, resolveProductPath } from '../../../lib/seo/resolve-path';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The trail, once — rendered as the visible breadcrumbs AND as the
|
|
31
|
+
* `BreadcrumbList` a crawler reads. Two lists would be two claims about where
|
|
32
|
+
* this page sits, and nothing in the template would notice them diverging.
|
|
33
|
+
*/
|
|
34
|
+
const BREADCRUMB_TRAIL: readonly { name: string; path: string }[] = [
|
|
35
|
+
{ name: 'Home', path: '/' },
|
|
36
|
+
{ name: 'Products', path: '/products' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One product read per request, shared by `generateMetadata` and the page.
|
|
41
|
+
*
|
|
42
|
+
* Next invokes them as separate calls, so without React's request cache the
|
|
43
|
+
* slug law would run against two independent fetches — and a product renamed
|
|
44
|
+
* between them would emit metadata for one address while redirecting to
|
|
45
|
+
* another.
|
|
46
|
+
*/
|
|
47
|
+
const productForRequest = cache(async (slug: string) => getProductBySlug(slug));
|
|
12
48
|
|
|
13
49
|
// PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
|
|
14
50
|
// per-request holes streamed inside their Suspense boundaries
|
|
15
|
-
// (
|
|
51
|
+
// (see next.config.js for why every route is request-rendered).
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Indexability for a product URL comes from the same resolution the page acts
|
|
55
|
+
* on, through the shared request cache — so the tag and the status can never
|
|
56
|
+
* disagree about what this URL is.
|
|
57
|
+
*
|
|
58
|
+
* Two content-level refusals ride here that no route rule could know: a
|
|
59
|
+
* FALLBACK copy (this language has no real content, so indexing it would put
|
|
60
|
+
* near-duplicate text in competition with the language that does) and the
|
|
61
|
+
* merchant's own `indexable: false`. Redirect and notFound need no directive —
|
|
62
|
+
* their metadata is discarded with the response body.
|
|
63
|
+
*
|
|
64
|
+
* This is the one route whose title, description and social image are the
|
|
65
|
+
* MERCHANT's rather than the template's: they come from the per-language SEO
|
|
66
|
+
* sidecar through `entityMetadata`, already resolved into the request's
|
|
67
|
+
* language by the shop API, so a German product page carries a German title
|
|
68
|
+
* without this file knowing anything about translation.
|
|
69
|
+
*/
|
|
70
|
+
export async function generateMetadata({
|
|
71
|
+
params,
|
|
72
|
+
searchParams,
|
|
73
|
+
}: {
|
|
74
|
+
params: Promise<{ slug: string }>;
|
|
75
|
+
searchParams: Promise<RouteSearchParams>;
|
|
76
|
+
}): Promise<Metadata> {
|
|
77
|
+
const [{ slug }, resolvedSearchParams] = await Promise.all([params, searchParams]);
|
|
78
|
+
const { binding, shopName, channelResolved } = await getRequestLocale();
|
|
79
|
+
const product = await productForRequest(slug);
|
|
80
|
+
const resolution = resolveProductPath({ requestedSlug: slug, product, binding });
|
|
81
|
+
const entity = product ? entityMetadata(product, binding.locale) : null;
|
|
82
|
+
return routeMetadata({
|
|
83
|
+
binding,
|
|
84
|
+
shopName,
|
|
85
|
+
channelResolved,
|
|
86
|
+
pathname: `/products/${slug}`,
|
|
87
|
+
searchParams: resolvedSearchParams,
|
|
88
|
+
// Unlike a structural route, a product earns its alternates per language:
|
|
89
|
+
// the map is the intersection of "has a slug" and "has content", so an
|
|
90
|
+
// untranslated language never appears. `buildAlternates` then returns
|
|
91
|
+
// undefined for a fallback copy — no canonical, no hreflang — because a
|
|
92
|
+
// derived page has no address of its own to claim and must not be pulled
|
|
93
|
+
// into a reciprocal set it is not a member of. An absent product yields an
|
|
94
|
+
// empty map, which lands in that same arm rather than needing its own.
|
|
95
|
+
pathsByLocale: product ? productPathsByLocale(product) : {},
|
|
96
|
+
title: entity?.title ?? null,
|
|
97
|
+
description: entity?.description,
|
|
98
|
+
socialImage: entity?.socialImage,
|
|
99
|
+
contentIndexable: resolution.kind === 'ok' ? resolution.indexable : false,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
16
102
|
|
|
17
103
|
/**
|
|
18
104
|
* Product detail (Blueprint `pages/product-detail`): identity block ordering —
|
|
@@ -21,25 +107,121 @@ import {
|
|
|
21
107
|
* shoppers keep their place in the catalog.
|
|
22
108
|
*/
|
|
23
109
|
// Next.js 15: route params are delivered as a Promise.
|
|
24
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The slug law runs in the page SHELL, above the Suspense boundary, and that
|
|
112
|
+
* placement is the whole correctness of the route's status codes.
|
|
113
|
+
*
|
|
114
|
+
* `notFound()` and `permanentRedirect()` can only set a status where Next
|
|
115
|
+
* assigns one. Raised from inside a boundary — as the product lookup used to
|
|
116
|
+
* be — the shell has already flushed with a 200 and the throw resolves as
|
|
117
|
+
* client-rendered content instead: the response is a **200 carrying a
|
|
118
|
+
* not-found page**. A crawler drops a real 404 and INDEXES a soft one, so an
|
|
119
|
+
* unknown slug would have put an error page into the index under a URL that
|
|
120
|
+
* will never hold content. (Measured: `/products/no-such-product` answered 200
|
|
121
|
+
* before this moved.)
|
|
122
|
+
*
|
|
123
|
+
* The cost is that the product read is no longer streamed — but it is the one
|
|
124
|
+
* thing this page IS, so a skeleton bought nothing while the only content
|
|
125
|
+
* loaded. The genuinely secondary read (per-variant selling plans) stays in the
|
|
126
|
+
* boundary below, which is what streaming is for.
|
|
127
|
+
*/
|
|
128
|
+
export default async function ProductDetailPage({
|
|
129
|
+
params,
|
|
130
|
+
searchParams,
|
|
131
|
+
}: {
|
|
132
|
+
params: Promise<{ slug: string }>;
|
|
133
|
+
searchParams: Promise<RouteSearchParams>;
|
|
134
|
+
}) {
|
|
135
|
+
const { binding } = await getRequestLocale();
|
|
136
|
+
const [{ slug }, resolvedSearchParams] = await Promise.all([params, searchParams]);
|
|
137
|
+
const product = await productForRequest(slug);
|
|
138
|
+
const resolution = resolveProductPath({ requestedSlug: slug, product, binding });
|
|
139
|
+
|
|
140
|
+
// `product === null` IS the resolution's notFound arm — spelled out rather
|
|
141
|
+
// than matched on `resolution.kind` so TypeScript narrows `product` for the
|
|
142
|
+
// render below (`notFound()` returns never). Matching the kind would leave it
|
|
143
|
+
// nullable and force a cast that asserts what the law already guarantees.
|
|
144
|
+
if (product === null) notFound();
|
|
145
|
+
if (resolution.kind === 'redirect') permanentRedirect(resolution.to);
|
|
146
|
+
|
|
25
147
|
return (
|
|
26
148
|
<div className="space-y-8">
|
|
149
|
+
{structuredData(product, binding, slug, resolvedSearchParams, resolution).map((node) => (
|
|
150
|
+
<script
|
|
151
|
+
key={node['@type']}
|
|
152
|
+
type="application/ld+json"
|
|
153
|
+
// The ONE way to put JSON-LD in a document; `serializeJsonLd` escapes
|
|
154
|
+
// `<`, so a description carrying `</script>` cannot close the element.
|
|
155
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(node) }}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
27
158
|
<Suspense fallback={<DetailSkeleton />}>
|
|
28
|
-
<ProductDetail
|
|
159
|
+
<ProductDetail product={product} locale={binding} />
|
|
29
160
|
</Suspense>
|
|
30
161
|
</div>
|
|
31
162
|
);
|
|
32
163
|
}
|
|
33
164
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
165
|
+
/**
|
|
166
|
+
* The structured data this page is entitled to emit — empty whenever it is not.
|
|
167
|
+
*
|
|
168
|
+
* Gated on the SAME `routeRobots` the metadata uses, because a page telling
|
|
169
|
+
* crawlers to ignore it while handing them a machine-readable description of
|
|
170
|
+
* its contents is making two opposite claims at once. `undefined` from that
|
|
171
|
+
* call is the one mechanism's way of saying "this is a real, indexable
|
|
172
|
+
* address", and it already covers the fallback copy, the merchant's own
|
|
173
|
+
* `indexable: false`, query-state views and the missing public origin.
|
|
174
|
+
*
|
|
175
|
+
* The origin is read ONCE and handed to both claims this function makes — the
|
|
176
|
+
* canonical `buildAlternates` derives, and the breadcrumb item URLs — so the
|
|
177
|
+
* two can never name different hosts. Its null arm is what narrows it to a
|
|
178
|
+
* string for those URLs; it cannot be taken here, because no-origin is one of
|
|
179
|
+
* the reasons the gate above would already have closed.
|
|
180
|
+
*/
|
|
181
|
+
function structuredData(
|
|
182
|
+
product: ProductWithSeo,
|
|
183
|
+
binding: LocaleBinding,
|
|
184
|
+
slug: string,
|
|
185
|
+
searchParams: RouteSearchParams,
|
|
186
|
+
resolution: ReturnType<typeof resolveProductPath>,
|
|
187
|
+
): JsonLdNode[] {
|
|
188
|
+
const contentIndexable = resolution.kind === 'ok' ? resolution.indexable : false;
|
|
189
|
+
if (routeRobots(`/products/${slug}`, searchParams, contentIndexable) !== undefined) return [];
|
|
190
|
+
|
|
191
|
+
const publicOrigin = getPublicOrigin();
|
|
192
|
+
const alternates = buildAlternates({
|
|
193
|
+
binding,
|
|
194
|
+
pathsByLocale: productPathsByLocale(product),
|
|
195
|
+
publicOrigin,
|
|
196
|
+
});
|
|
197
|
+
if (!alternates || publicOrigin === null) return [];
|
|
39
198
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
199
|
+
return [
|
|
200
|
+
productJsonLd({
|
|
201
|
+
name: product.name,
|
|
202
|
+
description: product.description,
|
|
203
|
+
image: product.featuredAsset?.preview,
|
|
204
|
+
canonical: alternates.canonical,
|
|
205
|
+
variants: product.variants,
|
|
206
|
+
}),
|
|
207
|
+
breadcrumbJsonLd({
|
|
208
|
+
binding,
|
|
209
|
+
publicOrigin,
|
|
210
|
+
leafName: product.name,
|
|
211
|
+
trail: BREADCRUMB_TRAIL,
|
|
212
|
+
}),
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function ProductDetail({
|
|
217
|
+
product,
|
|
218
|
+
locale: binding,
|
|
219
|
+
}: {
|
|
220
|
+
product: ProductWithSeo;
|
|
221
|
+
locale: LocaleBinding;
|
|
222
|
+
}) {
|
|
223
|
+
// Request-time hole: streamed inside its Suspense boundary.
|
|
224
|
+
await connection();
|
|
43
225
|
|
|
44
226
|
// Subscription selling plans are eligible per variant, so fetch the groups for
|
|
45
227
|
// each of the product's variants (in parallel) and pass a variantId -> groups
|
|
@@ -60,12 +242,13 @@ async function ProductDetail({ params }: { params: Promise<{ slug: string }> })
|
|
|
60
242
|
<>
|
|
61
243
|
<div className="breadcrumbs text-sm text-base-content/60">
|
|
62
244
|
<ul>
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
245
|
+
{BREADCRUMB_TRAIL.map((crumb) => (
|
|
246
|
+
<li key={crumb.path}>
|
|
247
|
+
<LocaleLink href={crumb.path} locale={binding}>
|
|
248
|
+
{crumb.name}
|
|
249
|
+
</LocaleLink>
|
|
250
|
+
</li>
|
|
251
|
+
))}
|
|
69
252
|
<li>
|
|
70
253
|
<span aria-current="page">{product.name}</span>
|
|
71
254
|
</li>
|
|
@@ -78,7 +261,7 @@ async function ProductDetail({ params }: { params: Promise<{ slug: string }> })
|
|
|
78
261
|
// eslint-disable-next-line @next/next/no-img-element
|
|
79
262
|
<img
|
|
80
263
|
src={product.featuredAsset.preview}
|
|
81
|
-
alt={product.
|
|
264
|
+
alt={getAssetAlt(product.featuredAsset)}
|
|
82
265
|
className="h-full w-full object-cover"
|
|
83
266
|
/>
|
|
84
267
|
) : (
|
|
@@ -1,30 +1,65 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
1
2
|
import { connection } from 'next/server';
|
|
2
3
|
import { Suspense } from 'react';
|
|
3
4
|
|
|
4
5
|
import { ProductCard } from '../../components/ProductCard';
|
|
5
6
|
import { getProducts } from '../../lib/forgecart';
|
|
7
|
+
import type { LocaleBinding } from '../../lib/locale/localized-path';
|
|
8
|
+
import { getRequestLocale } from '../../lib/locale/request-binding';
|
|
9
|
+
import { staticPathsByLocale } from '../../lib/seo/alternates';
|
|
10
|
+
import { routeMetadata } from '../../lib/seo/metadata';
|
|
11
|
+
import type { RouteSearchParams } from '../../lib/seo/noindex';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The catalog is indexable; a SORTED catalog is the same products in another
|
|
15
|
+
* order, so `?sort=` is a view of this page rather than a page of its own.
|
|
16
|
+
*
|
|
17
|
+
* The title is the template's own chrome, in English, exactly like this page's
|
|
18
|
+
* `<h1>`: structural surfaces are not translated content, and inventing a
|
|
19
|
+
* translation layer for them here would put a second, weaker source of truth
|
|
20
|
+
* beside the shop API's. The pages whose titles a merchant actually cares
|
|
21
|
+
* about — products — take theirs from the localized SEO sidecar.
|
|
22
|
+
*/
|
|
23
|
+
export async function generateMetadata({
|
|
24
|
+
searchParams,
|
|
25
|
+
}: {
|
|
26
|
+
searchParams: Promise<RouteSearchParams>;
|
|
27
|
+
}): Promise<Metadata> {
|
|
28
|
+
const [resolvedSearchParams, { binding, languageCodes, shopName, channelResolved }] =
|
|
29
|
+
await Promise.all([searchParams, getRequestLocale()]);
|
|
30
|
+
return routeMetadata({
|
|
31
|
+
binding,
|
|
32
|
+
shopName,
|
|
33
|
+
channelResolved,
|
|
34
|
+
pathname: '/products',
|
|
35
|
+
pathsByLocale: staticPathsByLocale(languageCodes, '/products'),
|
|
36
|
+
searchParams: resolvedSearchParams,
|
|
37
|
+
title: 'All products',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
6
40
|
|
|
7
41
|
// PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
|
|
8
42
|
// per-request holes streamed inside their Suspense boundaries
|
|
9
|
-
// (
|
|
43
|
+
// (see next.config.js for why every route is request-rendered).
|
|
10
44
|
|
|
11
45
|
/**
|
|
12
46
|
* Catalog — the browse grid. The header carries the count as orientation
|
|
13
47
|
* (Blueprint listing guidance: state scope up front); cards do the visual
|
|
14
48
|
* lifting, the grid stays quiet.
|
|
15
49
|
*/
|
|
16
|
-
export default function ProductsPage() {
|
|
50
|
+
export default async function ProductsPage() {
|
|
51
|
+
const { binding } = await getRequestLocale();
|
|
17
52
|
return (
|
|
18
53
|
<div className="space-y-8">
|
|
19
54
|
<Suspense fallback={<CatalogSkeleton />}>
|
|
20
|
-
<Catalog />
|
|
55
|
+
<Catalog locale={binding} />
|
|
21
56
|
</Suspense>
|
|
22
57
|
</div>
|
|
23
58
|
);
|
|
24
59
|
}
|
|
25
60
|
|
|
26
|
-
async function Catalog() {
|
|
27
|
-
// Request-time hole
|
|
61
|
+
async function Catalog({ locale }: { locale: LocaleBinding }) {
|
|
62
|
+
// Request-time hole: streamed inside its Suspense boundary.
|
|
28
63
|
await connection();
|
|
29
64
|
const { items, totalItems } = await getProducts({ take: 24 });
|
|
30
65
|
|
|
@@ -44,7 +79,7 @@ async function Catalog() {
|
|
|
44
79
|
) : (
|
|
45
80
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
|
|
46
81
|
{items.map((product) => (
|
|
47
|
-
<ProductCard key={product.id} product={product} />
|
|
82
|
+
<ProductCard key={product.id} product={product} locale={locale} />
|
|
48
83
|
))}
|
|
49
84
|
</div>
|
|
50
85
|
)}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
|
|
3
|
+
import { RegisterForm } from '../../components/account/RegisterForm';
|
|
4
|
+
import { getRequestLocale } from '../../lib/locale/request-binding';
|
|
5
|
+
import { routeMetadata } from '../../lib/seo/metadata';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `/register` — where a shopper creates an account (#1471).
|
|
9
|
+
*
|
|
10
|
+
* The page renders a form and nothing else: it asks the shop API nothing on
|
|
11
|
+
* GET, so unlike `/verify` there is no request-time hole to stream, and unlike
|
|
12
|
+
* `/reset-password` there is no token in the URL to read. Everything that
|
|
13
|
+
* happens here happens on the SUBMIT, over the shopper's own websocket.
|
|
14
|
+
*
|
|
15
|
+
* Unlike the two account-LINK routes, this one IS locale-prefixable — a
|
|
16
|
+
* shopper addresses it, from a link on this storefront, rather than receiving
|
|
17
|
+
* it in a mail — so `register` is deliberately absent from the grammar's
|
|
18
|
+
* unprefixed set and `/de/register` serves the German render.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const PAGE_TITLE = 'Create an account';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Noindex via `NOINDEX_PATHS`: a form with no content of its own competes with
|
|
25
|
+
* nothing, and it is the same posture `/cart` and `/checkout` take.
|
|
26
|
+
*
|
|
27
|
+
* No alternates, despite the route existing in every language. Hreflang is a
|
|
28
|
+
* claim about which address of a cluster a crawler should prefer, and a page
|
|
29
|
+
* that has just declined to be indexed at all has no such preference to state;
|
|
30
|
+
* the empty map is what makes `buildAlternates` withhold the claim rather than
|
|
31
|
+
* this page hand-rolling a robots value of its own.
|
|
32
|
+
*/
|
|
33
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
34
|
+
const { binding, shopName, channelResolved } = await getRequestLocale();
|
|
35
|
+
return routeMetadata({
|
|
36
|
+
binding,
|
|
37
|
+
shopName,
|
|
38
|
+
channelResolved,
|
|
39
|
+
pathname: '/register',
|
|
40
|
+
pathsByLocale: {},
|
|
41
|
+
title: PAGE_TITLE,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default async function RegisterPage() {
|
|
46
|
+
const { binding } = await getRequestLocale();
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="mx-auto max-w-xl space-y-6">
|
|
50
|
+
<h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
|
|
51
|
+
<RegisterForm locale={binding} />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
|
|
3
|
+
import { RequestPasswordResetForm } from '../../components/account/RequestPasswordResetForm';
|
|
4
|
+
import { ResetPasswordForm } from '../../components/account/ResetPasswordForm';
|
|
5
|
+
import { readLinkToken } from '../../lib/account/account-link';
|
|
6
|
+
import { getRequestLocale } from '../../lib/locale/request-binding';
|
|
7
|
+
import { routeMetadata } from '../../lib/seo/metadata';
|
|
8
|
+
import type { RouteSearchParams } from '../../lib/seo/noindex';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `/reset-password` — the page the platform's password-reset e-mail links to,
|
|
12
|
+
* and the place a shopper asks for that e-mail in the first place (#1472).
|
|
13
|
+
*
|
|
14
|
+
* ONE route, two modes, chosen by the URL's token:
|
|
15
|
+
*
|
|
16
|
+
* - `?token=…` — set a new password. The token is spent by the SUBMIT, never
|
|
17
|
+
* by the render: unlike verification, the shopper has something to type, so
|
|
18
|
+
* consuming the token on arrival would burn it before they got the chance.
|
|
19
|
+
* - no token — ask for a link. A shopper who lets one expire has nowhere else
|
|
20
|
+
* to go, and sending them back to the mail that is already stale is a dead
|
|
21
|
+
* end; the same address they were mailed at is all this form needs.
|
|
22
|
+
*
|
|
23
|
+
* Both modes render forms and nothing else, so unlike `/verify` there is no
|
|
24
|
+
* request-time hole to stream — the page reads the request and renders.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const PAGE_TITLE = 'Reset your password';
|
|
28
|
+
|
|
29
|
+
/** Noindex, and no alternates — for the reasons `/verify`'s metadata states. */
|
|
30
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
31
|
+
const { binding, shopName, channelResolved } = await getRequestLocale();
|
|
32
|
+
return routeMetadata({
|
|
33
|
+
binding,
|
|
34
|
+
shopName,
|
|
35
|
+
channelResolved,
|
|
36
|
+
pathname: '/reset-password',
|
|
37
|
+
pathsByLocale: {},
|
|
38
|
+
title: PAGE_TITLE,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default async function ResetPasswordPage({
|
|
43
|
+
searchParams,
|
|
44
|
+
}: {
|
|
45
|
+
searchParams: Promise<RouteSearchParams>;
|
|
46
|
+
}) {
|
|
47
|
+
const [params, { binding }] = await Promise.all([searchParams, getRequestLocale()]);
|
|
48
|
+
const token = readLinkToken(params);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="mx-auto max-w-xl space-y-6">
|
|
52
|
+
<h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
|
|
53
|
+
{token === null ? (
|
|
54
|
+
<RequestPasswordResetForm locale={binding} />
|
|
55
|
+
) : (
|
|
56
|
+
<ResetPasswordForm token={token} locale={binding} />
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
|
|
3
|
+
import { NOINDEX_PATHS, NOINDEX_QUERY_KEYS } from '../lib/seo/noindex';
|
|
4
|
+
import { getPublicOrigin } from '../lib/seo/public-origin';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `robots.txt` (#1347, epic launch#54 W1-9) — the crawler-facing half of the
|
|
8
|
+
* indexability contract in `lib/seo/noindex.ts`.
|
|
9
|
+
*
|
|
10
|
+
* FORCE-DYNAMIC, and that is load-bearing rather than cautious. Next
|
|
11
|
+
* pre-renders metadata routes at build time by default, and a storefront is
|
|
12
|
+
* built once then run with its per-channel environment supplied afterwards. A
|
|
13
|
+
* build-time answer would freeze the BUILDER's environment — which never has a
|
|
14
|
+
* channel's public origin — into `Disallow: /` for every store shipped from
|
|
15
|
+
* that image, permanently, with no way to correct it short of a rebuild. The
|
|
16
|
+
* scaffold is frozen at provisioning time (#1041), so "permanently" is literal.
|
|
17
|
+
*/
|
|
18
|
+
export const dynamic = 'force-dynamic';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Infrastructure routes that are never a destination for a human or a crawler:
|
|
22
|
+
* the tracking relay, the error beacon, the SDK proxy and the health probe.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT derived from the locale grammar's `UNPREFIXED_FIRST_SEGMENTS`
|
|
25
|
+
* despite the overlap. That set answers "which first segments are not a
|
|
26
|
+
* language?" and correctly contains `sitemap.xml`, `robots.txt`, `_next` and
|
|
27
|
+
* `favicon.ico` — every one of which a crawler MUST be allowed to fetch.
|
|
28
|
+
* Sharing one list would make the next infrastructure route silently
|
|
29
|
+
* uncrawlable, or a crawlable asset silently unprefixed.
|
|
30
|
+
*/
|
|
31
|
+
const INFRASTRUCTURE_DISALLOW: readonly string[] = [
|
|
32
|
+
'/api/',
|
|
33
|
+
'/__fc/',
|
|
34
|
+
'/__forge_beacon',
|
|
35
|
+
'/ping',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Without a public origin the storefront asserts nothing, so `robots.txt`
|
|
40
|
+
* refuses the whole tree. With one, it mirrors the noindex set — the page
|
|
41
|
+
* rules are DERIVED from `noindex.ts` rather than restated, because a
|
|
42
|
+
* `robots.txt` that disagrees with the pages' own `robots` metadata is the
|
|
43
|
+
* exact drift this module exists to prevent.
|
|
44
|
+
*
|
|
45
|
+
* The `Sitemap:` line rides the origin BRANCH, not merely the origin value: the
|
|
46
|
+
* URL it names has to be absolute, and the document it points at is empty
|
|
47
|
+
* without an origin. Announcing an empty sitemap is a claim about the catalog;
|
|
48
|
+
* announcing nothing is the absence of one, which is all a deployment that has
|
|
49
|
+
* not been told its own address is entitled to say.
|
|
50
|
+
*/
|
|
51
|
+
export default function robots(): MetadataRoute.Robots {
|
|
52
|
+
const publicOrigin = getPublicOrigin();
|
|
53
|
+
if (publicOrigin === null) {
|
|
54
|
+
return { rules: { userAgent: '*', disallow: '/' } };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
rules: {
|
|
59
|
+
userAgent: '*',
|
|
60
|
+
allow: '/',
|
|
61
|
+
disallow: [
|
|
62
|
+
...NOINDEX_PATHS,
|
|
63
|
+
...INFRASTRUCTURE_DISALLOW,
|
|
64
|
+
...NOINDEX_QUERY_KEYS.map((key) => `/*?${key}=`),
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
sitemap: `${publicOrigin}/sitemap.xml`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
|
|
3
|
+
import { getProductSeoEntries, getServedPageRoutes } from '../lib/forgecart';
|
|
4
|
+
import { resolveChannelLocales } from '../lib/locale/channel-locales-loader';
|
|
5
|
+
import { getPublicOrigin } from '../lib/seo/public-origin';
|
|
6
|
+
import { readScaffoldedRoutes } from '../lib/seo/scaffolded-routes';
|
|
7
|
+
import { createSitemapCache, sitemapWindowMs } from '../lib/seo/sitemap-cache';
|
|
8
|
+
import {
|
|
9
|
+
MAX_SITEMAP_URLS,
|
|
10
|
+
STATIC_ROUTES,
|
|
11
|
+
collectProductSitemapUrls,
|
|
12
|
+
mergeScaffoldedRoutes,
|
|
13
|
+
pageSitemapRoutes,
|
|
14
|
+
sitemapBudgetWarning,
|
|
15
|
+
staticSitemapUrls,
|
|
16
|
+
} from '../lib/seo/sitemap-entries';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `sitemap.xml` (#1347, epic launch#54 W1-9) — the discovery half of the SEO
|
|
20
|
+
* contract, listing every URL this deployment is willing to be indexed at.
|
|
21
|
+
*
|
|
22
|
+
* FORCE-DYNAMIC for the same load-bearing reason as `robots.ts`: Next
|
|
23
|
+
* pre-renders metadata routes at build time, the storefront image is built
|
|
24
|
+
* once and handed its channel environment afterwards, and the scaffold is
|
|
25
|
+
* frozen at provisioning time (#1041). A build-time answer would freeze the
|
|
26
|
+
* BUILDER's empty environment — no origin, no channel, no catalog — into an
|
|
27
|
+
* empty sitemap for every store shipped from that image, permanently. The
|
|
28
|
+
* 60s window is held in-process by `createSitemapCache` for exactly the same
|
|
29
|
+
* reason ISR is refused here: `revalidate` would pre-render the first response
|
|
30
|
+
* at build time too, which is the identical failure wearing a TTL.
|
|
31
|
+
*/
|
|
32
|
+
export const dynamic = 'force-dynamic';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An empty document without a public origin, and no `<lastmod>` claims — the
|
|
36
|
+
* same refusal `robots.ts` and every page's `robots` metadata make in that
|
|
37
|
+
* state. Every URL a sitemap lists is absolute, so a deployment with no origin
|
|
38
|
+
* has nothing truthful to put in one.
|
|
39
|
+
*
|
|
40
|
+
* A backend failure is deliberately NOT caught: an unreachable shop makes this
|
|
41
|
+
* route 5xx, which tells a crawler to come back. Serving an empty sitemap
|
|
42
|
+
* instead would assert that the catalog is empty — the one answer that is
|
|
43
|
+
* actively false, and the one a crawler would act on by dropping URLs.
|
|
44
|
+
*/
|
|
45
|
+
const cache = createSitemapCache(build, { ttlMs: sitemapWindowMs });
|
|
46
|
+
|
|
47
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
48
|
+
return cache.get();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The refusal is inside the window rather than guarding it, because the origin
|
|
53
|
+
* is memoized for the life of the process: an unconfigured deployment stays
|
|
54
|
+
* unconfigured until it restarts, so caching its empty answer cannot make it
|
|
55
|
+
* any staler than the memo already has.
|
|
56
|
+
*/
|
|
57
|
+
async function build(): Promise<MetadataRoute.Sitemap> {
|
|
58
|
+
const publicOrigin = getPublicOrigin();
|
|
59
|
+
if (publicOrigin === null) return [];
|
|
60
|
+
|
|
61
|
+
const { channel } = await resolveChannelLocales(null);
|
|
62
|
+
const { languageCodes, defaultLanguageCode } = channel;
|
|
63
|
+
|
|
64
|
+
// The template's own routes, every page scaffolded since, and every ACF page
|
|
65
|
+
// the shop says it currently SERVES (#1934). Read per build rather than at
|
|
66
|
+
// module load, so a page created a moment ago is in the document on the next
|
|
67
|
+
// window — the reason `createStorefrontPage` writes a row at all, and the
|
|
68
|
+
// reason the ACF feed is a live query rather than a file: an ACF page is
|
|
69
|
+
// published through the API and writes nothing to this pod's disk.
|
|
70
|
+
//
|
|
71
|
+
// PRECEDENCE falls out of the merge and is worth naming: the base table wins
|
|
72
|
+
// over both, and within the appended list the FIRST claimant of a path wins —
|
|
73
|
+
// so a code-scaffolded page at `/pages/x` outranks an ACF page at the same
|
|
74
|
+
// slug. That is also how Next routes them (a literal `app/pages/x/page.tsx`
|
|
75
|
+
// shadows the dynamic `[slug]`), so the document cannot disagree with the
|
|
76
|
+
// router about which page an address serves.
|
|
77
|
+
const [scaffolded, servedPageRoutes] = await Promise.all([
|
|
78
|
+
readScaffoldedRoutes(),
|
|
79
|
+
getServedPageRoutes(defaultLanguageCode),
|
|
80
|
+
]);
|
|
81
|
+
const routes = mergeScaffoldedRoutes(STATIC_ROUTES, [
|
|
82
|
+
...scaffolded,
|
|
83
|
+
...pageSitemapRoutes(servedPageRoutes),
|
|
84
|
+
]);
|
|
85
|
+
const staticUrls = staticSitemapUrls(languageCodes, defaultLanguageCode, publicOrigin, routes);
|
|
86
|
+
const productUrls = await collectProductSitemapUrls(
|
|
87
|
+
async (skip, take) => getProductSeoEntries(defaultLanguageCode, { skip, take }),
|
|
88
|
+
defaultLanguageCode,
|
|
89
|
+
publicOrigin,
|
|
90
|
+
Math.max(0, MAX_SITEMAP_URLS - staticUrls.length),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const urls = [...staticUrls, ...productUrls];
|
|
94
|
+
|
|
95
|
+
// The one place this template writes to the console, and it earns it: a
|
|
96
|
+
// catalog that outgrows a single XML sitemap would otherwise stop being
|
|
97
|
+
// submitted for indexing in complete silence, inside the feature whose whole
|
|
98
|
+
// job is getting pages indexed. There is no logger in a scaffolded storefront
|
|
99
|
+
// and no channelId to tag — only the channel token, which is a credential and
|
|
100
|
+
// must never reach a log — so the pod's stdout is the channel, and the 60s
|
|
101
|
+
// window bounds this to at most one line a minute.
|
|
102
|
+
const warning = sitemapBudgetWarning(urls.length);
|
|
103
|
+
if (warning !== null) console.warn(warning);
|
|
104
|
+
|
|
105
|
+
return urls;
|
|
106
|
+
}
|