@forgecart/cli 2.202610052143.0 → 2.202610060357.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 (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,317 @@
1
+ import type { Metadata } from 'next';
2
+ import { notFound, permanentRedirect } from 'next/navigation';
3
+ import { connection } from 'next/server';
4
+ import { Suspense, cache } from 'react';
5
+
6
+ import { ProductPurchase } from '../../../components/ProductPurchase';
7
+ import { LocaleLink } from '../../../components/LocaleLink';
8
+ import { Skeleton } from '../../../components/ui/skeleton';
9
+ import { getAssetAlt } from '../../../lib/asset-alt';
10
+ import {
11
+ getProductBySlug,
12
+ getSellingPlanGroupsForVariant,
13
+ type ProductWithSeo,
14
+ type SellingPlanGroup,
15
+ } from '../../../lib/forgecart';
16
+ import type { LocaleBinding } from '../../../lib/locale/localized-path';
17
+ import { getRequestLocale } from '../../../lib/locale/request-binding';
18
+ import { buildAlternates } from '../../../lib/seo/alternates';
19
+ import {
20
+ breadcrumbJsonLd,
21
+ productJsonLd,
22
+ serializeJsonLd,
23
+ type JsonLdNode,
24
+ } from '../../../lib/seo/json-ld';
25
+ import { entityMetadata, routeMetadata } from '../../../lib/seo/metadata';
26
+ import { routeRobots, type RouteSearchParams } from '../../../lib/seo/noindex';
27
+ import { getPublicOrigin } from '../../../lib/seo/public-origin';
28
+ import { productPathsByLocale, resolveProductPath } from '../../../lib/seo/resolve-path';
29
+
30
+ /**
31
+ * The trail, once — rendered as the visible breadcrumbs AND as the
32
+ * `BreadcrumbList` a crawler reads. Two lists would be two claims about where
33
+ * this page sits, and nothing in the template would notice them diverging.
34
+ */
35
+ const BREADCRUMB_TRAIL: readonly { name: string; path: string }[] = [
36
+ { name: 'Home', path: '/' },
37
+ { name: 'Products', path: '/products' },
38
+ ];
39
+
40
+ /**
41
+ * One product read per request, shared by `generateMetadata` and the page.
42
+ *
43
+ * Next invokes them as separate calls, so without React's request cache the
44
+ * slug law would run against two independent fetches — and a product renamed
45
+ * between them would emit metadata for one address while redirecting to
46
+ * another.
47
+ */
48
+ const productForRequest = cache(async (slug: string) => getProductBySlug(slug));
49
+
50
+ // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
51
+ // per-request holes streamed inside their Suspense boundaries
52
+ // (see next.config.js for why every route is request-rendered).
53
+
54
+ /**
55
+ * Indexability for a product URL comes from the same resolution the page acts
56
+ * on, through the shared request cache — so the tag and the status can never
57
+ * disagree about what this URL is.
58
+ *
59
+ * Two content-level refusals ride here that no route rule could know: a
60
+ * FALLBACK copy (this language has no real content, so indexing it would put
61
+ * near-duplicate text in competition with the language that does) and the
62
+ * merchant's own `indexable: false`. Redirect and notFound need no directive —
63
+ * their metadata is discarded with the response body.
64
+ *
65
+ * Like an ACF page's (`/pages/<route>`, #1375), this route's title, description
66
+ * and social image are the MERCHANT's rather than the template's: they come
67
+ * from the per-language SEO sidecar through `entityMetadata`, already resolved
68
+ * into the request's language by the shop API, so a German product page
69
+ * carries a German title without this file knowing anything about translation.
70
+ */
71
+ export async function generateMetadata({
72
+ params,
73
+ searchParams,
74
+ }: {
75
+ params: Promise<{ slug: string }>;
76
+ searchParams: Promise<RouteSearchParams>;
77
+ }): Promise<Metadata> {
78
+ const [{ slug }, resolvedSearchParams] = await Promise.all([params, searchParams]);
79
+ const { binding, shopName, channelResolved } = await getRequestLocale();
80
+ const product = await productForRequest(slug);
81
+ const resolution = resolveProductPath({ requestedSlug: slug, product, binding });
82
+ const entity = product ? entityMetadata(product, binding.locale) : null;
83
+ return routeMetadata({
84
+ binding,
85
+ shopName,
86
+ channelResolved,
87
+ pathname: `/products/${slug}`,
88
+ searchParams: resolvedSearchParams,
89
+ // Unlike a structural route, a product earns its alternates per language:
90
+ // the map is the intersection of "has a slug" and "has content", so an
91
+ // untranslated language never appears. `buildAlternates` then returns
92
+ // undefined for a fallback copy — no canonical, no hreflang — because a
93
+ // derived page has no address of its own to claim and must not be pulled
94
+ // into a reciprocal set it is not a member of. An absent product yields an
95
+ // empty map, which lands in that same arm rather than needing its own.
96
+ pathsByLocale: product ? productPathsByLocale(product) : {},
97
+ title: entity?.title ?? null,
98
+ description: entity?.description,
99
+ socialImage: entity?.socialImage,
100
+ contentIndexable: resolution.kind === 'ok' ? resolution.indexable : false,
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Product detail (Blueprint `pages/product-detail`): identity block ordering —
106
+ * media, then title, then price/variants/CTA (ProductPurchase owns those),
107
+ * then the long-form description. Breadcrumbs replace the bare back-link so
108
+ * shoppers keep their place in the catalog.
109
+ */
110
+ // Next.js 15: route params are delivered as a Promise.
111
+ /**
112
+ * The slug law runs in the page SHELL, above the Suspense boundary, and that
113
+ * placement is the whole correctness of the route's status codes.
114
+ *
115
+ * `notFound()` and `permanentRedirect()` can only set a status where Next
116
+ * assigns one. Raised from inside a boundary — as the product lookup used to
117
+ * be — the shell has already flushed with a 200 and the throw resolves as
118
+ * client-rendered content instead: the response is a **200 carrying a
119
+ * not-found page**. A crawler drops a real 404 and INDEXES a soft one, so an
120
+ * unknown slug would have put an error page into the index under a URL that
121
+ * will never hold content. (Measured: `/products/no-such-product` answered 200
122
+ * before this moved.)
123
+ *
124
+ * The cost is that the product read is no longer streamed — but it is the one
125
+ * thing this page IS, so a skeleton bought nothing while the only content
126
+ * loaded. The genuinely secondary read (per-variant selling plans) stays in the
127
+ * boundary below, which is what streaming is for.
128
+ */
129
+ export default async function ProductDetailPage({
130
+ params,
131
+ searchParams,
132
+ }: {
133
+ params: Promise<{ slug: string }>;
134
+ searchParams: Promise<RouteSearchParams>;
135
+ }) {
136
+ const { binding } = await getRequestLocale();
137
+ const [{ slug }, resolvedSearchParams] = await Promise.all([params, searchParams]);
138
+ const product = await productForRequest(slug);
139
+ const resolution = resolveProductPath({ requestedSlug: slug, product, binding });
140
+
141
+ // `product === null` IS the resolution's notFound arm — spelled out rather
142
+ // than matched on `resolution.kind` so TypeScript narrows `product` for the
143
+ // render below (`notFound()` returns never). Matching the kind would leave it
144
+ // nullable and force a cast that asserts what the law already guarantees.
145
+ if (product === null) notFound();
146
+ if (resolution.kind === 'redirect') permanentRedirect(resolution.to);
147
+
148
+ return (
149
+ <div className="space-y-8">
150
+ {structuredData(product, binding, slug, resolvedSearchParams, resolution).map((node) => (
151
+ <script
152
+ key={node['@type']}
153
+ type="application/ld+json"
154
+ // The ONE way to put JSON-LD in a document; `serializeJsonLd` escapes
155
+ // `<`, so a description carrying `</script>` cannot close the element.
156
+ dangerouslySetInnerHTML={{ __html: serializeJsonLd(node) }}
157
+ />
158
+ ))}
159
+ <Suspense fallback={<DetailSkeleton />}>
160
+ <ProductDetail product={product} locale={binding} />
161
+ </Suspense>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ /**
167
+ * The structured data this page is entitled to emit — empty whenever it is not.
168
+ *
169
+ * Gated on the SAME `routeRobots` the metadata uses, because a page telling
170
+ * crawlers to ignore it while handing them a machine-readable description of
171
+ * its contents is making two opposite claims at once. `undefined` from that
172
+ * call is the one mechanism's way of saying "this is a real, indexable
173
+ * address", and it already covers the fallback copy, the merchant's own
174
+ * `indexable: false`, query-state views and the missing public origin.
175
+ *
176
+ * The origin is read ONCE and handed to both claims this function makes — the
177
+ * canonical `buildAlternates` derives, and the breadcrumb item URLs — so the
178
+ * two can never name different hosts. Its null arm is what narrows it to a
179
+ * string for those URLs; it cannot be taken here, because no-origin is one of
180
+ * the reasons the gate above would already have closed.
181
+ */
182
+ function structuredData(
183
+ product: ProductWithSeo,
184
+ binding: LocaleBinding,
185
+ slug: string,
186
+ searchParams: RouteSearchParams,
187
+ resolution: ReturnType<typeof resolveProductPath>,
188
+ ): JsonLdNode[] {
189
+ const contentIndexable = resolution.kind === 'ok' ? resolution.indexable : false;
190
+ if (routeRobots(`/products/${slug}`, searchParams, contentIndexable) !== undefined) return [];
191
+
192
+ const publicOrigin = getPublicOrigin();
193
+ const alternates = buildAlternates({
194
+ binding,
195
+ pathsByLocale: productPathsByLocale(product),
196
+ publicOrigin,
197
+ });
198
+ if (!alternates || publicOrigin === null) return [];
199
+
200
+ return [
201
+ productJsonLd({
202
+ name: product.name,
203
+ description: product.description,
204
+ image: product.featuredAsset?.preview,
205
+ canonical: alternates.canonical,
206
+ variants: product.variants,
207
+ }),
208
+ breadcrumbJsonLd({
209
+ binding,
210
+ publicOrigin,
211
+ leafName: product.name,
212
+ trail: BREADCRUMB_TRAIL,
213
+ }),
214
+ ];
215
+ }
216
+
217
+ async function ProductDetail({
218
+ product,
219
+ locale: binding,
220
+ }: {
221
+ product: ProductWithSeo;
222
+ locale: LocaleBinding;
223
+ }) {
224
+ // Request-time hole: streamed inside its Suspense boundary.
225
+ await connection();
226
+
227
+ // Subscription selling plans are eligible per variant, so fetch the groups for
228
+ // each of the product's variants (in parallel) and pass a variantId -> groups
229
+ // map to the client purchase control. Variants with no plans get the plain
230
+ // one-time add-to-cart path.
231
+ const planGroupsByVariant: Record<string, SellingPlanGroup[]> = Object.fromEntries(
232
+ await Promise.all(
233
+ product.variants.map(
234
+ async (variant): Promise<[string, SellingPlanGroup[]]> => [
235
+ variant.id,
236
+ await getSellingPlanGroupsForVariant(variant.id),
237
+ ],
238
+ ),
239
+ ),
240
+ );
241
+
242
+ return (
243
+ <>
244
+ <nav aria-label="Breadcrumb" className="text-sm text-muted-foreground">
245
+ <ol className="flex flex-wrap items-center gap-1.5 break-words">
246
+ {BREADCRUMB_TRAIL.map((crumb) => (
247
+ <li key={crumb.path} className="inline-flex items-center gap-1.5">
248
+ <LocaleLink
249
+ href={crumb.path}
250
+ locale={binding}
251
+ className="transition-colors hover:text-foreground"
252
+ >
253
+ {crumb.name}
254
+ </LocaleLink>
255
+ <span aria-hidden="true">/</span>
256
+ </li>
257
+ ))}
258
+ <li className="inline-flex items-center">
259
+ <span aria-current="page" className="text-foreground">
260
+ {product.name}
261
+ </span>
262
+ </li>
263
+ </ol>
264
+ </nav>
265
+
266
+ <div className="grid gap-8 lg:grid-cols-2 lg:gap-12">
267
+ <figure className="aspect-square overflow-hidden rounded-xl border bg-muted lg:sticky lg:top-24 lg:self-start">
268
+ {product.featuredAsset ? (
269
+ // eslint-disable-next-line @next/next/no-img-element
270
+ <img
271
+ src={product.featuredAsset.preview}
272
+ alt={getAssetAlt(product.featuredAsset)}
273
+ className="h-full w-full object-cover"
274
+ />
275
+ ) : (
276
+ <div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
277
+ No image
278
+ </div>
279
+ )}
280
+ </figure>
281
+
282
+ <div className="space-y-6">
283
+ <h1 className="text-3xl font-bold tracking-tight text-balance">{product.name}</h1>
284
+
285
+ {/* Pick a variant (Size/Color) -> price + purchase options + add-to-cart. */}
286
+ <ProductPurchase product={product} planGroupsByVariant={planGroupsByVariant} />
287
+
288
+ {product.description && (
289
+ <section aria-label="Product description" className="border-t pt-6">
290
+ <div
291
+ className="max-w-none text-sm leading-relaxed text-muted-foreground"
292
+ // Product descriptions may contain rich text from the dashboard.
293
+ dangerouslySetInnerHTML={{ __html: product.description }}
294
+ />
295
+ </section>
296
+ )}
297
+ </div>
298
+ </div>
299
+ </>
300
+ );
301
+ }
302
+
303
+ function DetailSkeleton() {
304
+ return (
305
+ <div aria-busy="true">
306
+ <Skeleton className="h-4 w-56" />
307
+ <div className="mt-8 grid gap-8 lg:grid-cols-2 lg:gap-12">
308
+ <Skeleton className="aspect-square w-full" />
309
+ <div className="space-y-4">
310
+ <Skeleton className="h-9 w-3/4" />
311
+ <Skeleton className="h-24 w-full" />
312
+ <Skeleton className="h-12 w-full" />
313
+ </div>
314
+ </div>
315
+ </div>
316
+ );
317
+ }
@@ -0,0 +1,107 @@
1
+ import type { Metadata } from 'next';
2
+ import { connection } from 'next/server';
3
+ import { Suspense } from 'react';
4
+
5
+ import { ProductCard } from '../../components/ProductCard';
6
+ import { Skeleton } from '../../components/ui/skeleton';
7
+ import { getProducts } from '../../lib/forgecart';
8
+ import type { LocaleBinding } from '../../lib/locale/localized-path';
9
+ import { getRequestLocale } from '../../lib/locale/request-binding';
10
+ import { staticPathsByLocale } from '../../lib/seo/alternates';
11
+ import { routeMetadata } from '../../lib/seo/metadata';
12
+ import type { RouteSearchParams } from '../../lib/seo/noindex';
13
+
14
+ /**
15
+ * The catalog is indexable; a SORTED catalog is the same products in another
16
+ * order, so `?sort=` is a view of this page rather than a page of its own.
17
+ *
18
+ * The title is the template's own chrome, in English, exactly like this page's
19
+ * `<h1>`: structural surfaces are not translated content, and inventing a
20
+ * translation layer for them here would put a second, weaker source of truth
21
+ * beside the shop API's. The pages whose titles a merchant actually cares
22
+ * about — products and the merchant's own ACF pages — take theirs from the
23
+ * localized SEO sidecar.
24
+ */
25
+ export async function generateMetadata({
26
+ searchParams,
27
+ }: {
28
+ searchParams: Promise<RouteSearchParams>;
29
+ }): Promise<Metadata> {
30
+ const [resolvedSearchParams, { binding, languageCodes, shopName, channelResolved }] =
31
+ await Promise.all([searchParams, getRequestLocale()]);
32
+ return routeMetadata({
33
+ binding,
34
+ shopName,
35
+ channelResolved,
36
+ pathname: '/products',
37
+ pathsByLocale: staticPathsByLocale(languageCodes, '/products'),
38
+ searchParams: resolvedSearchParams,
39
+ title: 'All products',
40
+ });
41
+ }
42
+
43
+ // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
44
+ // per-request holes streamed inside their Suspense boundaries
45
+ // (see next.config.js for why every route is request-rendered).
46
+
47
+ /**
48
+ * Catalog — the browse grid. The header carries the count as orientation
49
+ * (Blueprint listing guidance: state scope up front); cards do the visual
50
+ * lifting, the grid stays quiet.
51
+ */
52
+ export default async function ProductsPage() {
53
+ const { binding } = await getRequestLocale();
54
+ return (
55
+ <div className="space-y-8">
56
+ <Suspense fallback={<CatalogSkeleton />}>
57
+ <Catalog locale={binding} />
58
+ </Suspense>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ async function Catalog({ locale }: { locale: LocaleBinding }) {
64
+ // Request-time hole: streamed inside its Suspense boundary.
65
+ await connection();
66
+ const { items, totalItems } = await getProducts({ take: 24 });
67
+
68
+ return (
69
+ <>
70
+ <header className="flex flex-wrap items-baseline justify-between gap-3 border-b pb-6">
71
+ <h1 className="text-3xl font-bold tracking-tight">All products</h1>
72
+ <span className="text-sm text-muted-foreground">
73
+ {totalItems} {totalItems === 1 ? 'item' : 'items'}
74
+ </span>
75
+ </header>
76
+
77
+ {items.length === 0 ? (
78
+ <p className="text-muted-foreground">
79
+ No products yet — add your first product in the dashboard and it appears here.
80
+ </p>
81
+ ) : (
82
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
83
+ {items.map((product) => (
84
+ <ProductCard key={product.id} product={product} locale={locale} />
85
+ ))}
86
+ </div>
87
+ )}
88
+ </>
89
+ );
90
+ }
91
+
92
+ function CatalogSkeleton() {
93
+ return (
94
+ <div aria-busy="true">
95
+ <header className="flex items-baseline justify-between gap-3 border-b pb-6">
96
+ <h1 className="text-3xl font-bold tracking-tight">All products</h1>
97
+ <Skeleton className="h-4 w-16" />
98
+ </header>
99
+ <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
100
+ <Skeleton className="aspect-square w-full" />
101
+ <Skeleton className="aspect-square w-full" />
102
+ <Skeleton className="aspect-square w-full" />
103
+ <Skeleton className="aspect-square w-full" />
104
+ </div>
105
+ </div>
106
+ );
107
+ }
@@ -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
+ }