@forgecart/cli 2.202610052310.0 → 2.202610071755.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/package.json +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- 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
|
+
}
|