@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
|
@@ -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() {
|
|
@@ -1,35 +1,89 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
|
-
import
|
|
2
|
+
import { notFound, permanentRedirect } from 'next/navigation';
|
|
3
3
|
import { Suspense, type ReactNode } from 'react';
|
|
4
4
|
|
|
5
5
|
import { ForgeErrorBeacon } from '../components/ForgeErrorBeacon';
|
|
6
6
|
import { ForgeTracker } from '../components/ForgeTracker';
|
|
7
7
|
import { ForgecartDesigner } from '../components/ForgecartDesigner';
|
|
8
8
|
import { Header } from '../components/Header';
|
|
9
|
+
import { LocaleLink } from '../components/LocaleLink';
|
|
9
10
|
import { CartProvider } from '../lib/cart-context';
|
|
11
|
+
import { getRequestLocale } from '../lib/locale/request-binding';
|
|
12
|
+
import { shellMetadata } from '../lib/seo/metadata';
|
|
10
13
|
import { getBrowserShopConfig } from '../lib/shop-config';
|
|
11
14
|
|
|
12
15
|
import './globals.css';
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The deployment-wide metadata floor.
|
|
19
|
+
*
|
|
20
|
+
* Resolved per REQUEST rather than declared as a static `metadata` object,
|
|
21
|
+
* because everything in it arrives in the environment of the running server
|
|
22
|
+
* rather than of the machine that built the bundle: the public origin from
|
|
23
|
+
* `.env` (see `lib/seo/public-origin.ts`) and the shop name from the channel
|
|
24
|
+
* itself. A build-time answer would bake the builder's empty environment into
|
|
25
|
+
* every store shipped from that image.
|
|
26
|
+
*
|
|
27
|
+
* The title is the STORE's name, so a merchant who scaffolds a page and writes
|
|
28
|
+
* no metadata gets their own store in the browser tab instead of the word
|
|
29
|
+
* `Storefront` — which is what every page of every ForgeCart store displayed
|
|
30
|
+
* before this. Route-specific claims live on the routes; this is only the
|
|
31
|
+
* floor, and it deliberately carries no canonical (it does not know which
|
|
32
|
+
* route it is wrapping).
|
|
33
|
+
*
|
|
34
|
+
* It reads the SAME resolution the layout body reads below — React's request
|
|
35
|
+
* cache makes `getRequestLocale()` one channel read per request no matter how
|
|
36
|
+
* many callers there are, so the floor costs nothing extra.
|
|
37
|
+
*/
|
|
38
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
39
|
+
const { shopName, channelResolved } = await getRequestLocale();
|
|
40
|
+
return shellMetadata({ shopName, channelResolved });
|
|
41
|
+
}
|
|
18
42
|
|
|
19
43
|
/**
|
|
20
|
-
* The
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
44
|
+
* The root layout — DYNAMIC on every route, by design (D8).
|
|
45
|
+
*
|
|
46
|
+
* It reads the request locale to write `<html lang>`, and `lang` is an
|
|
47
|
+
* attribute on the document root: structurally outside every Suspense
|
|
48
|
+
* boundary, so it cannot come from a streamed hole. That single requirement
|
|
49
|
+
* decides the rendering model for the whole storefront, and `next.config.js`
|
|
50
|
+
* carries the reasoning — Cache Components is off because the escape hatch it
|
|
51
|
+
* would force (a `<Suspense>` above `<body>`) is exactly what makes the two
|
|
52
|
+
* throws below unable to set a status.
|
|
53
|
+
*
|
|
54
|
+
* Those throws are the locale contract's teeth. Raised HERE, in the shell,
|
|
55
|
+
* they reach the one place Next assigns a status: `permanentRedirect` gives a
|
|
56
|
+
* real 308 with a `Location`, and `notFound` a real 404. Raised from inside
|
|
57
|
+
* any boundary they would resolve as client-rendered content instead, and the
|
|
58
|
+
* response would be a 200 carrying a 404 page — a soft-404, which a crawler
|
|
59
|
+
* indexes rather than drops.
|
|
60
|
+
*
|
|
61
|
+
* One consequence worth knowing: on `notFound()` Next discards the app tree
|
|
62
|
+
* and seeds a bare error document, so `app/not-found.tsx` reaches the shopper
|
|
63
|
+
* on client hydration rather than in the SSR body. The STATUS is correct
|
|
64
|
+
* either way, which is what the crawler consumes.
|
|
65
|
+
*
|
|
66
|
+
* The cart still hydrates CLIENT-side over the shopper's own websocket
|
|
67
|
+
* (`CartProvider` + `shop-session.ts`), armed with the channel coordinates
|
|
68
|
+
* serialized here (public storefront token — the admin secret never takes this
|
|
69
|
+
* path).
|
|
25
70
|
*/
|
|
26
|
-
export default function RootLayout({ children }: { children: ReactNode }) {
|
|
27
|
-
const
|
|
71
|
+
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
72
|
+
const { decision, binding, languageCodes } = await getRequestLocale();
|
|
73
|
+
if (decision.kind === 'redirect') permanentRedirect(decision.to);
|
|
74
|
+
if (decision.kind === 'notFound') notFound();
|
|
75
|
+
|
|
76
|
+
// The shopper's own socket is opened CLIENT-side, so it cannot read the
|
|
77
|
+
// request headers the server resolved the locale from. It gets the resolved
|
|
78
|
+
// language here, which keeps one source of truth — the URL — for both the
|
|
79
|
+
// server's per-locale clients and the browser's session client. Without it
|
|
80
|
+
// the cart would answer in the channel default on a German page.
|
|
81
|
+
const shopConfig = getBrowserShopConfig(binding.locale);
|
|
28
82
|
return (
|
|
29
|
-
<html lang=
|
|
83
|
+
<html lang={binding.locale}>
|
|
30
84
|
<body className="min-h-screen bg-base-200 text-base-content antialiased">
|
|
31
85
|
<CartProvider shopConfig={shopConfig}>
|
|
32
|
-
<Header />
|
|
86
|
+
<Header locale={binding} languageCodes={languageCodes} />
|
|
33
87
|
<main className="mx-auto max-w-6xl px-4 py-10">{children}</main>
|
|
34
88
|
<footer className="footer footer-horizontal mt-8 items-center border-t border-base-300 bg-base-100 px-6 py-8 text-sm text-base-content/60 sm:px-10">
|
|
35
89
|
<aside>
|
|
@@ -38,22 +92,31 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|
|
38
92
|
</aside>
|
|
39
93
|
<nav aria-label="Footer" className="sm:justify-self-end">
|
|
40
94
|
<div className="grid grid-flow-col gap-4">
|
|
41
|
-
<
|
|
95
|
+
<LocaleLink href="/" locale={binding} className="link link-hover">
|
|
42
96
|
Home
|
|
43
|
-
</
|
|
44
|
-
<
|
|
97
|
+
</LocaleLink>
|
|
98
|
+
<LocaleLink href="/products" locale={binding} className="link link-hover">
|
|
45
99
|
Products
|
|
46
|
-
</
|
|
47
|
-
<
|
|
100
|
+
</LocaleLink>
|
|
101
|
+
<LocaleLink href="/cart" locale={binding} className="link link-hover">
|
|
48
102
|
Cart
|
|
49
|
-
</
|
|
103
|
+
</LocaleLink>
|
|
104
|
+
{/* The only affordance for `/register` (#1471). It sits in the
|
|
105
|
+
footer rather than the header nav on purpose: the template
|
|
106
|
+
ships no sign-in surface yet, and an account entry in the
|
|
107
|
+
primary nav would advertise an account AREA that does not
|
|
108
|
+
exist. A merchant who adds sign-in moves it up. */}
|
|
109
|
+
<LocaleLink href="/register" locale={binding} className="link link-hover">
|
|
110
|
+
Create account
|
|
111
|
+
</LocaleLink>
|
|
50
112
|
</div>
|
|
51
113
|
</nav>
|
|
52
114
|
</footer>
|
|
53
115
|
</CartProvider>
|
|
54
116
|
{/* Invisible client plumbing. The tracker reads usePathname(), which
|
|
55
|
-
|
|
56
|
-
|
|
117
|
+
needs a boundary of its own — null fallback, these render nothing.
|
|
118
|
+
This must stay INSIDE <body>: a boundary above it would make the
|
|
119
|
+
shell resumable and cost the statuses above. */}
|
|
57
120
|
<Suspense fallback={null}>
|
|
58
121
|
<ForgecartDesigner />
|
|
59
122
|
<ForgeErrorBeacon />
|
|
@@ -1,19 +1,56 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
2
|
import { connection } from 'next/server';
|
|
3
3
|
import { Suspense } from 'react';
|
|
4
4
|
import { cache } from 'react';
|
|
5
5
|
|
|
6
|
+
import { LocaleLink } from '../components/LocaleLink';
|
|
6
7
|
import { ProductCard } from '../components/ProductCard';
|
|
8
|
+
import { getAssetAlt } from '../lib/asset-alt';
|
|
7
9
|
import { getVariant } from '../lib/experiments';
|
|
8
10
|
import { getFeaturedProducts } from '../lib/forgecart';
|
|
11
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
12
|
+
import { getRequestLocale } from '../lib/locale/request-binding';
|
|
13
|
+
import { staticPathsByLocale } from '../lib/seo/alternates';
|
|
14
|
+
import { routeMetadata } from '../lib/seo/metadata';
|
|
15
|
+
import type { RouteSearchParams } from '../lib/seo/noindex';
|
|
9
16
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
17
|
+
// The data sections below are per-request holes streamed inside their Suspense
|
|
18
|
+
// boundaries. The page itself is dynamic like every route (the root layout
|
|
19
|
+
// reads the request locale — see next.config.js).
|
|
13
20
|
|
|
14
21
|
/** One featured read per request, shared by both holes (React request cache). */
|
|
15
22
|
const featured = cache(async () => getFeaturedProducts(4));
|
|
16
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The landing surface is indexable — unless the URL carries query state
|
|
26
|
+
* (an experiment pin, a preview) that makes it a VIEW of the page competing
|
|
27
|
+
* with the page's own address.
|
|
28
|
+
*
|
|
29
|
+
* `title: null` because the store's landing page has no name of its own: it IS
|
|
30
|
+
* the store, so it takes the shop name alone rather than composing something
|
|
31
|
+
* like `Home | Acme`. The description is the shell's, inherited by omission.
|
|
32
|
+
*/
|
|
33
|
+
export async function generateMetadata({
|
|
34
|
+
searchParams,
|
|
35
|
+
}: {
|
|
36
|
+
searchParams: Promise<RouteSearchParams>;
|
|
37
|
+
}): Promise<Metadata> {
|
|
38
|
+
const [resolvedSearchParams, { binding, languageCodes, shopName, channelResolved }] =
|
|
39
|
+
await Promise.all([searchParams, getRequestLocale()]);
|
|
40
|
+
return routeMetadata({
|
|
41
|
+
binding,
|
|
42
|
+
shopName,
|
|
43
|
+
channelResolved,
|
|
44
|
+
// The canonical is built from the BASE path, so `/?fc-exp=hero:b` declares
|
|
45
|
+
// `/` as its address — the query-state copy points at the page it is a view
|
|
46
|
+
// of, which is the whole reason those URLs are noindex rather than ignored.
|
|
47
|
+
pathname: '/',
|
|
48
|
+
pathsByLocale: staticPathsByLocale(languageCodes, '/'),
|
|
49
|
+
searchParams: resolvedSearchParams,
|
|
50
|
+
title: null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
/**
|
|
18
55
|
* Home — the store's landing surface (Blueprint `pages/product-landing-page`):
|
|
19
56
|
* a product-led hero with ONE dominant conversion action, an editorial
|
|
@@ -21,11 +58,12 @@ const featured = cache(async () => getFeaturedProducts(4));
|
|
|
21
58
|
* and a closing conversion band. Every color is a semantic token; the brand
|
|
22
59
|
* lives in the globals.css theme block alone.
|
|
23
60
|
*/
|
|
24
|
-
export default function HomePage() {
|
|
61
|
+
export default async function HomePage() {
|
|
62
|
+
const { binding } = await getRequestLocale();
|
|
25
63
|
return (
|
|
26
64
|
<div className="space-y-16">
|
|
27
65
|
<Suspense fallback={<HeroSkeleton />}>
|
|
28
|
-
<Hero />
|
|
66
|
+
<Hero locale={binding} />
|
|
29
67
|
</Suspense>
|
|
30
68
|
|
|
31
69
|
<section aria-labelledby="featured-heading">
|
|
@@ -33,12 +71,16 @@ export default function HomePage() {
|
|
|
33
71
|
<h2 id="featured-heading" className="text-2xl font-semibold tracking-tight">
|
|
34
72
|
Featured
|
|
35
73
|
</h2>
|
|
36
|
-
<
|
|
74
|
+
<LocaleLink
|
|
75
|
+
href="/products"
|
|
76
|
+
locale={binding}
|
|
77
|
+
className="link link-hover text-sm text-base-content/60"
|
|
78
|
+
>
|
|
37
79
|
View all products
|
|
38
|
-
</
|
|
80
|
+
</LocaleLink>
|
|
39
81
|
</div>
|
|
40
82
|
<Suspense fallback={<GridSkeleton />}>
|
|
41
|
-
<FeaturedGrid />
|
|
83
|
+
<FeaturedGrid locale={binding} />
|
|
42
84
|
</Suspense>
|
|
43
85
|
</section>
|
|
44
86
|
</div>
|
|
@@ -53,8 +95,8 @@ export default function HomePage() {
|
|
|
53
95
|
* and the default copy renders. `data-fc-variant` mirrors the assignment for
|
|
54
96
|
* debugging/verification; exposure tracking happens server-side.
|
|
55
97
|
*/
|
|
56
|
-
async function Hero() {
|
|
57
|
-
// Request-time hole
|
|
98
|
+
async function Hero({ locale }: { locale: LocaleBinding }) {
|
|
99
|
+
// Request-time hole: streamed inside its Suspense boundary.
|
|
58
100
|
await connection();
|
|
59
101
|
const [products, hero] = await Promise.all([featured(), getVariant('hero-headline')]);
|
|
60
102
|
const heroProduct = products.find((product) => product.featuredAsset) ?? null;
|
|
@@ -75,14 +117,15 @@ async function Hero() {
|
|
|
75
117
|
: 'This storefront is served by a ForgeCart channel. Browse the catalog and add products to your cart.'}
|
|
76
118
|
</p>
|
|
77
119
|
<div className="mt-8">
|
|
78
|
-
<
|
|
120
|
+
<LocaleLink href="/products" locale={locale} className="btn btn-primary btn-lg">
|
|
79
121
|
Shop all products
|
|
80
|
-
</
|
|
122
|
+
</LocaleLink>
|
|
81
123
|
</div>
|
|
82
124
|
</div>
|
|
83
125
|
{heroProduct?.featuredAsset && (
|
|
84
|
-
<
|
|
126
|
+
<LocaleLink
|
|
85
127
|
href={`/products/${heroProduct.slug}`}
|
|
128
|
+
locale={locale}
|
|
86
129
|
className="w-full max-w-md lg:flex-1"
|
|
87
130
|
aria-label={`View ${heroProduct.name}`}
|
|
88
131
|
>
|
|
@@ -91,18 +134,18 @@ async function Hero() {
|
|
|
91
134
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
92
135
|
<img
|
|
93
136
|
src={heroProduct.featuredAsset.preview}
|
|
94
|
-
alt={heroProduct.
|
|
137
|
+
alt={getAssetAlt(heroProduct.featuredAsset)}
|
|
95
138
|
className="h-full w-full object-cover"
|
|
96
139
|
/>
|
|
97
140
|
</figure>
|
|
98
|
-
</
|
|
141
|
+
</LocaleLink>
|
|
99
142
|
)}
|
|
100
143
|
</div>
|
|
101
144
|
</section>
|
|
102
145
|
);
|
|
103
146
|
}
|
|
104
147
|
|
|
105
|
-
async function FeaturedGrid() {
|
|
148
|
+
async function FeaturedGrid({ locale }: { locale: LocaleBinding }) {
|
|
106
149
|
await connection();
|
|
107
150
|
const products = await featured();
|
|
108
151
|
|
|
@@ -119,7 +162,7 @@ async function FeaturedGrid() {
|
|
|
119
162
|
<div className="grid grid-cols-2 gap-4 sm:gap-6 lg:grid-cols-4">
|
|
120
163
|
{products.map((product, index) => (
|
|
121
164
|
<div key={product.id} className={index === 0 ? 'col-span-2 row-span-2' : undefined}>
|
|
122
|
-
<ProductCard product={product} emphasis={index === 0} />
|
|
165
|
+
<ProductCard product={product} locale={locale} emphasis={index === 0} />
|
|
123
166
|
</div>
|
|
124
167
|
))}
|
|
125
168
|
</div>
|
|
@@ -132,9 +175,9 @@ async function FeaturedGrid() {
|
|
|
132
175
|
<p className="max-w-md text-neutral-content/70 text-pretty">
|
|
133
176
|
The whole catalog ships from this store — browse everything in one place.
|
|
134
177
|
</p>
|
|
135
|
-
<
|
|
178
|
+
<LocaleLink href="/products" locale={locale} className="btn btn-primary btn-wide">
|
|
136
179
|
Browse the catalog
|
|
137
|
-
</
|
|
180
|
+
</LocaleLink>
|
|
138
181
|
</div>
|
|
139
182
|
</section>
|
|
140
183
|
</>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { LocaleLink } from '../../../components/LocaleLink';
|
|
2
|
+
import { getRequestLocale } from '../../../lib/locale/request-binding';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a visitor sees at a page URL that holds nothing: an unknown route, or a
|
|
6
|
+
* page whose content is not published (or not published YET). The copy does not
|
|
7
|
+
* distinguish them on purpose — "this is scheduled for Tuesday" is the
|
|
8
|
+
* merchant's information, not the public's.
|
|
9
|
+
*/
|
|
10
|
+
export default async function AcfPageNotFound() {
|
|
11
|
+
const { binding } = await getRequestLocale();
|
|
12
|
+
return (
|
|
13
|
+
<div className="space-y-4">
|
|
14
|
+
<h1 className="text-2xl font-bold tracking-tight text-base-content">Page not found</h1>
|
|
15
|
+
<p className="text-base-content/60">
|
|
16
|
+
We couldn't find that page. It may have been moved or taken down.
|
|
17
|
+
</p>
|
|
18
|
+
<LocaleLink href="/" locale={binding} className="btn btn-primary">
|
|
19
|
+
Back to the shop
|
|
20
|
+
</LocaleLink>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { notFound } from 'next/navigation';
|
|
3
|
+
import { cache } from 'react';
|
|
4
|
+
|
|
5
|
+
import { PageFields } from '../../../lib/content/render-fields';
|
|
6
|
+
import { resolvePage } from '../../../lib/content/resolve-page';
|
|
7
|
+
import {
|
|
8
|
+
getPageByRoute,
|
|
9
|
+
getPageEntries,
|
|
10
|
+
type PageEntry,
|
|
11
|
+
type PageGroup,
|
|
12
|
+
} from '../../../lib/forgecart';
|
|
13
|
+
import { getRequestLocale } from '../../../lib/locale/request-binding';
|
|
14
|
+
import { staticPathsByLocale } from '../../../lib/seo/alternates';
|
|
15
|
+
import { routeMetadata } from '../../../lib/seo/metadata';
|
|
16
|
+
import type { RouteSearchParams } from '../../../lib/seo/noindex';
|
|
17
|
+
import { PAGE_ROUTE_PREFIX } from '../../../lib/seo/sitemap-entries';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The merchant's own pages (#1934, epic launch#50), served at the route the
|
|
21
|
+
* shop API minted for the page definition.
|
|
22
|
+
*
|
|
23
|
+
* Locale-prefixed addresses land here too and cost nothing: the route tree is
|
|
24
|
+
* FLAT by law (#1041) and the middleware rewrites `/de/pages/x` onto this same
|
|
25
|
+
* file with the locale carried on a request header, so `/pages/x` and every
|
|
26
|
+
* `/<xx>/pages/x` are one route with one decision.
|
|
27
|
+
*
|
|
28
|
+
* FORCE-DYNAMIC, explicitly. "An unpublished page answers 404 on the NEXT
|
|
29
|
+
* request" is this feature's contract, and a contract must not rest on the
|
|
30
|
+
* incidental fact that some module downstream happens to read `headers()`. No
|
|
31
|
+
* `revalidate`, no `use cache`: a window here would serve a page the merchant
|
|
32
|
+
* has already taken down, which is the one failure this route may not have.
|
|
33
|
+
*/
|
|
34
|
+
export const dynamic = 'force-dynamic';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The page's definition AND its visible record, read once per request.
|
|
38
|
+
*
|
|
39
|
+
* Both halves together, because the 404 decision needs both and Next invokes
|
|
40
|
+
* `generateMetadata` and the page as separate calls — without React's request
|
|
41
|
+
* cache the two would resolve independently, and a page unpublished between
|
|
42
|
+
* them would emit indexable metadata for a URL that then 404s.
|
|
43
|
+
*
|
|
44
|
+
* The entries read is skipped when nothing is mounted at the route: there is no
|
|
45
|
+
* definition code to ask about, and the resolution's answer is already settled.
|
|
46
|
+
*/
|
|
47
|
+
const pageForRequest = cache(
|
|
48
|
+
async (route: string): Promise<{ group: PageGroup | null; entries: PageEntry[] }> => {
|
|
49
|
+
const group = await getPageByRoute(route);
|
|
50
|
+
if (group === null) return { group: null, entries: [] };
|
|
51
|
+
return { group, entries: await getPageEntries(group.code) };
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A page's indexability comes from the same resolution the route acts on, so
|
|
57
|
+
* the tag and the status can never disagree about what this URL is.
|
|
58
|
+
*
|
|
59
|
+
* The title is the merchant's own page name — the one piece of per-page SEO
|
|
60
|
+
* that exists today. A page has no per-language SEO sidecar yet (#1375), so
|
|
61
|
+
* there is no description, no social image, and the alternate set is the
|
|
62
|
+
* structural one: a page route is the SAME address in every language the
|
|
63
|
+
* channel offers, exactly like `/products`.
|
|
64
|
+
*/
|
|
65
|
+
export async function generateMetadata({
|
|
66
|
+
params,
|
|
67
|
+
searchParams,
|
|
68
|
+
}: {
|
|
69
|
+
params: Promise<{ slug: string }>;
|
|
70
|
+
searchParams: Promise<RouteSearchParams>;
|
|
71
|
+
}): Promise<Metadata> {
|
|
72
|
+
const [{ slug }, resolvedSearchParams, { binding, languageCodes, shopName, channelResolved }] =
|
|
73
|
+
await Promise.all([params, searchParams, getRequestLocale()]);
|
|
74
|
+
const { group, entries } = await pageForRequest(slug);
|
|
75
|
+
const resolution = resolvePage({ requestedRoute: slug, group, entries });
|
|
76
|
+
|
|
77
|
+
return routeMetadata({
|
|
78
|
+
binding,
|
|
79
|
+
shopName,
|
|
80
|
+
channelResolved,
|
|
81
|
+
pathname: `${PAGE_ROUTE_PREFIX}/${slug}`,
|
|
82
|
+
pathsByLocale: staticPathsByLocale(languageCodes, `${PAGE_ROUTE_PREFIX}/${slug}`),
|
|
83
|
+
searchParams: resolvedSearchParams,
|
|
84
|
+
title: resolution.kind === 'ok' ? resolution.group.name : null,
|
|
85
|
+
contentIndexable: resolution.kind === 'ok',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The resolution runs in the page SHELL, above any boundary, and that placement
|
|
91
|
+
* is the whole correctness of this route's status codes: `notFound()` can only
|
|
92
|
+
* set a status where Next assigns one, and raised from inside a Suspense
|
|
93
|
+
* boundary the shell has already flushed a 200 — a crawler then INDEXES the
|
|
94
|
+
* not-found page instead of dropping it (the same law `products/[slug]`
|
|
95
|
+
* carries, measured there).
|
|
96
|
+
*
|
|
97
|
+
* There is no boundary at all here, and that follows from the same fact: the
|
|
98
|
+
* two reads ARE this page, so streaming a skeleton would buy nothing while the
|
|
99
|
+
* only content loaded.
|
|
100
|
+
*/
|
|
101
|
+
export default async function AcfPage({ params }: { params: Promise<{ slug: string }> }) {
|
|
102
|
+
const { slug } = await params;
|
|
103
|
+
const { group, entries } = await pageForRequest(slug);
|
|
104
|
+
const resolution = resolvePage({ requestedRoute: slug, group, entries });
|
|
105
|
+
|
|
106
|
+
if (resolution.kind === 'notFound') notFound();
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<article className="space-y-8">
|
|
110
|
+
<h1 className="text-3xl font-bold tracking-tight text-balance">{resolution.group.name}</h1>
|
|
111
|
+
<PageFields definitions={resolution.group.fieldDefinitions} entry={resolution.entry} />
|
|
112
|
+
</article>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* route handler renders nothing and touches no session, so readiness polling stays
|
|
10
10
|
* side-effect-free.
|
|
11
11
|
*
|
|
12
|
-
* Request access keeps it out of
|
|
12
|
+
* Request access keeps it out of any static prerender so a production build serves
|
|
13
13
|
* it on demand exactly like `next dev` does — the probe always gets a fresh 200.
|
|
14
14
|
*/
|
|
15
15
|
|
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { LocaleLink } from '../../../components/LocaleLink';
|
|
2
|
+
import { getRequestLocale } from '../../../lib/locale/request-binding';
|
|
2
3
|
|
|
3
|
-
export default function ProductNotFound() {
|
|
4
|
+
export default async function ProductNotFound() {
|
|
5
|
+
const { binding } = await getRequestLocale();
|
|
4
6
|
return (
|
|
5
7
|
<div className="space-y-4">
|
|
6
8
|
<h1 className="text-2xl font-bold tracking-tight text-base-content">Product not found</h1>
|
|
7
9
|
<p className="text-base-content/60">
|
|
8
10
|
We couldn't find that product. It may have been removed.
|
|
9
11
|
</p>
|
|
10
|
-
<
|
|
12
|
+
<LocaleLink href="/products" locale={binding} className="btn btn-primary">
|
|
11
13
|
Back to products
|
|
12
|
-
</
|
|
14
|
+
</LocaleLink>
|
|
13
15
|
</div>
|
|
14
16
|
);
|
|
15
17
|
}
|