@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.
Files changed (90) hide show
  1. package/dist/src/cli.js +3 -21
  2. package/dist/src/cli.js.map +1 -1
  3. package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
  4. package/dist/src/commands/__test__/cli-harness.js +29 -0
  5. package/dist/src/commands/__test__/cli-harness.js.map +1 -0
  6. package/dist/src/commands/init.d.ts +28 -2
  7. package/dist/src/commands/init.js +100 -15
  8. package/dist/src/commands/init.js.map +1 -1
  9. package/dist/src/commands/refresh.d.ts +40 -0
  10. package/dist/src/commands/refresh.js +147 -0
  11. package/dist/src/commands/refresh.js.map +1 -0
  12. package/dist/src/commands/template-manifest.d.ts +38 -0
  13. package/dist/src/commands/template-manifest.js +116 -0
  14. package/dist/src/commands/template-manifest.js.map +1 -0
  15. package/dist/src/version.d.ts +10 -0
  16. package/dist/src/version.js +25 -0
  17. package/dist/src/version.js.map +1 -0
  18. package/package.json +1 -1
  19. package/templates/storefront/README.md +42 -4
  20. package/templates/storefront/next.config.js +29 -7
  21. package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
  22. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
  23. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
  24. package/templates/storefront/src/app/cart/page.tsx +14 -2
  25. package/templates/storefront/src/app/checkout/page.tsx +14 -2
  26. package/templates/storefront/src/app/layout.tsx +85 -22
  27. package/templates/storefront/src/app/page.tsx +63 -20
  28. package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
  29. package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
  30. package/templates/storefront/src/app/ping/route.ts +1 -1
  31. package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
  32. package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
  33. package/templates/storefront/src/app/products/page.tsx +41 -6
  34. package/templates/storefront/src/app/register/page.tsx +54 -0
  35. package/templates/storefront/src/app/reset-password/page.tsx +60 -0
  36. package/templates/storefront/src/app/robots.ts +69 -0
  37. package/templates/storefront/src/app/sitemap.ts +106 -0
  38. package/templates/storefront/src/app/verify/page.tsx +155 -0
  39. package/templates/storefront/src/components/CartView.tsx +26 -7
  40. package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
  41. package/templates/storefront/src/components/Header.tsx +30 -10
  42. package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
  43. package/templates/storefront/src/components/LocaleLink.tsx +49 -0
  44. package/templates/storefront/src/components/ProductCard.tsx +10 -4
  45. package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
  46. package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
  47. package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
  48. package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
  49. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
  50. package/templates/storefront/src/lib/account/account-link.ts +76 -0
  51. package/templates/storefront/src/lib/account/register-state.ts +133 -0
  52. package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
  53. package/templates/storefront/src/lib/account/verify-state.ts +56 -0
  54. package/templates/storefront/src/lib/account-actions.ts +76 -0
  55. package/templates/storefront/src/lib/account-session.ts +47 -0
  56. package/templates/storefront/src/lib/asset-alt.ts +34 -0
  57. package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
  58. package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
  59. package/templates/storefront/src/lib/experiments.ts +1 -1
  60. package/templates/storefront/src/lib/forgecart.ts +300 -27
  61. package/templates/storefront/src/lib/format.ts +12 -14
  62. package/templates/storefront/src/lib/identify-forward.ts +152 -0
  63. package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
  64. package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
  65. package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
  66. package/templates/storefront/src/lib/locale/grammar.ts +194 -0
  67. package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
  68. package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
  69. package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
  70. package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
  71. package/templates/storefront/src/lib/marketing-params.ts +213 -0
  72. package/templates/storefront/src/lib/money.ts +50 -0
  73. package/templates/storefront/src/lib/seo/alternates.ts +120 -0
  74. package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
  75. package/templates/storefront/src/lib/seo/metadata.ts +323 -0
  76. package/templates/storefront/src/lib/seo/noindex.ts +218 -0
  77. package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
  78. package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
  79. package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
  80. package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
  81. package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
  82. package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
  83. package/templates/storefront/src/lib/session-actions.ts +15 -8
  84. package/templates/storefront/src/lib/session-cookies.ts +98 -0
  85. package/templates/storefront/src/lib/shop-config.ts +9 -2
  86. package/templates/storefront/src/lib/shop-session.ts +42 -5
  87. package/templates/storefront/src/lib/track-forward.ts +43 -14
  88. package/templates/storefront/src/middleware.ts +196 -16
  89. package/templates/storefront/src/seo/redirects.ts +44 -0
  90. 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 (cacheComponents). The session work (the order itself, every
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 Link from 'next/link';
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
- export const metadata: Metadata = {
15
- title: 'Storefront',
16
- description: 'A ForgeCart channel storefront.',
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 PPR-static shell: no cookie reads, no session work the layout renders
21
- * the same for every shopper. The cart hydrates CLIENT-side over the
22
- * shopper's own websocket (`CartProvider` + `shop-session.ts`), armed with
23
- * the channel coordinates serialized here (public storefront token — the
24
- * admin secret never takes this path).
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 shopConfig = getBrowserShopConfig();
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="en">
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
- <Link href="/" className="link link-hover">
95
+ <LocaleLink href="/" locale={binding} className="link link-hover">
42
96
  Home
43
- </Link>
44
- <Link href="/products" className="link link-hover">
97
+ </LocaleLink>
98
+ <LocaleLink href="/products" locale={binding} className="link link-hover">
45
99
  Products
46
- </Link>
47
- <Link href="/cart" className="link link-hover">
100
+ </LocaleLink>
101
+ <LocaleLink href="/cart" locale={binding} className="link link-hover">
48
102
  Cart
49
- </Link>
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
- cacheComponents only allows under a Suspense boundary — null
56
- fallback, these render nothing. */}
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 Link from 'next/link';
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
- // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
11
- // per-request holes streamed inside their Suspense boundaries
12
- // (cacheComponents — see next.config.js).
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
- <Link href="/products" className="link link-hover text-sm text-base-content/60">
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
- </Link>
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 (cacheComponents): the build keeps the fallback shell.
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
- <Link href="/products" className="btn btn-primary btn-lg">
120
+ <LocaleLink href="/products" locale={locale} className="btn btn-primary btn-lg">
79
121
  Shop all products
80
- </Link>
122
+ </LocaleLink>
81
123
  </div>
82
124
  </div>
83
125
  {heroProduct?.featuredAsset && (
84
- <Link
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.name}
137
+ alt={getAssetAlt(heroProduct.featuredAsset)}
95
138
  className="h-full w-full object-cover"
96
139
  />
97
140
  </figure>
98
- </Link>
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
- <Link href="/products" className="btn btn-primary btn-wide">
178
+ <LocaleLink href="/products" locale={locale} className="btn btn-primary btn-wide">
136
179
  Browse the catalog
137
- </Link>
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&apos;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 the static prerender (cacheComponents) so a production build serves
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 Link from 'next/link';
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&apos;t find that product. It may have been removed.
9
11
  </p>
10
- <Link href="/products" className="btn btn-primary">
12
+ <LocaleLink href="/products" locale={binding} className="btn btn-primary">
11
13
  Back to products
12
- </Link>
14
+ </LocaleLink>
13
15
  </div>
14
16
  );
15
17
  }