@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,106 @@
|
|
|
1
|
+
import type { MetadataRoute } from 'next';
|
|
2
|
+
|
|
3
|
+
import { getProductSeoEntries, getServedPageRoutes } from '../lib/forgecart';
|
|
4
|
+
import { resolveChannelLocales } from '../lib/locale/channel-locales-loader';
|
|
5
|
+
import { getPublicOrigin } from '../lib/seo/public-origin';
|
|
6
|
+
import { readScaffoldedRoutes } from '../lib/seo/scaffolded-routes';
|
|
7
|
+
import { createSitemapCache, sitemapWindowMs } from '../lib/seo/sitemap-cache';
|
|
8
|
+
import {
|
|
9
|
+
MAX_SITEMAP_URLS,
|
|
10
|
+
STATIC_ROUTES,
|
|
11
|
+
collectProductSitemapUrls,
|
|
12
|
+
mergeScaffoldedRoutes,
|
|
13
|
+
pageSitemapRoutes,
|
|
14
|
+
sitemapBudgetWarning,
|
|
15
|
+
staticSitemapUrls,
|
|
16
|
+
} from '../lib/seo/sitemap-entries';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `sitemap.xml` (#1347, epic launch#54 W1-9) — the discovery half of the SEO
|
|
20
|
+
* contract, listing every URL this deployment is willing to be indexed at.
|
|
21
|
+
*
|
|
22
|
+
* FORCE-DYNAMIC for the same load-bearing reason as `robots.ts`: Next
|
|
23
|
+
* pre-renders metadata routes at build time, the storefront image is built
|
|
24
|
+
* once and handed its channel environment afterwards, and the scaffold is
|
|
25
|
+
* frozen at provisioning time (#1041). A build-time answer would freeze the
|
|
26
|
+
* BUILDER's empty environment — no origin, no channel, no catalog — into an
|
|
27
|
+
* empty sitemap for every store shipped from that image, permanently. The
|
|
28
|
+
* 60s window is held in-process by `createSitemapCache` for exactly the same
|
|
29
|
+
* reason ISR is refused here: `revalidate` would pre-render the first response
|
|
30
|
+
* at build time too, which is the identical failure wearing a TTL.
|
|
31
|
+
*/
|
|
32
|
+
export const dynamic = 'force-dynamic';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* An empty document without a public origin, and no `<lastmod>` claims — the
|
|
36
|
+
* same refusal `robots.ts` and every page's `robots` metadata make in that
|
|
37
|
+
* state. Every URL a sitemap lists is absolute, so a deployment with no origin
|
|
38
|
+
* has nothing truthful to put in one.
|
|
39
|
+
*
|
|
40
|
+
* A backend failure is deliberately NOT caught: an unreachable shop makes this
|
|
41
|
+
* route 5xx, which tells a crawler to come back. Serving an empty sitemap
|
|
42
|
+
* instead would assert that the catalog is empty — the one answer that is
|
|
43
|
+
* actively false, and the one a crawler would act on by dropping URLs.
|
|
44
|
+
*/
|
|
45
|
+
const cache = createSitemapCache(build, { ttlMs: sitemapWindowMs });
|
|
46
|
+
|
|
47
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
48
|
+
return cache.get();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The refusal is inside the window rather than guarding it, because the origin
|
|
53
|
+
* is memoized for the life of the process: an unconfigured deployment stays
|
|
54
|
+
* unconfigured until it restarts, so caching its empty answer cannot make it
|
|
55
|
+
* any staler than the memo already has.
|
|
56
|
+
*/
|
|
57
|
+
async function build(): Promise<MetadataRoute.Sitemap> {
|
|
58
|
+
const publicOrigin = getPublicOrigin();
|
|
59
|
+
if (publicOrigin === null) return [];
|
|
60
|
+
|
|
61
|
+
const { channel } = await resolveChannelLocales(null);
|
|
62
|
+
const { languageCodes, defaultLanguageCode } = channel;
|
|
63
|
+
|
|
64
|
+
// The template's own routes, every page scaffolded since, and every ACF page
|
|
65
|
+
// the shop says it currently SERVES (#1934). Read per build rather than at
|
|
66
|
+
// module load, so a page created a moment ago is in the document on the next
|
|
67
|
+
// window — the reason `createStorefrontPage` writes a row at all, and the
|
|
68
|
+
// reason the ACF feed is a live query rather than a file: an ACF page is
|
|
69
|
+
// published through the API and writes nothing to this pod's disk.
|
|
70
|
+
//
|
|
71
|
+
// PRECEDENCE falls out of the merge and is worth naming: the base table wins
|
|
72
|
+
// over both, and within the appended list the FIRST claimant of a path wins —
|
|
73
|
+
// so a code-scaffolded page at `/pages/x` outranks an ACF page at the same
|
|
74
|
+
// slug. That is also how Next routes them (a literal `app/pages/x/page.tsx`
|
|
75
|
+
// shadows the dynamic `[slug]`), so the document cannot disagree with the
|
|
76
|
+
// router about which page an address serves.
|
|
77
|
+
const [scaffolded, servedPageRoutes] = await Promise.all([
|
|
78
|
+
readScaffoldedRoutes(),
|
|
79
|
+
getServedPageRoutes(defaultLanguageCode),
|
|
80
|
+
]);
|
|
81
|
+
const routes = mergeScaffoldedRoutes(STATIC_ROUTES, [
|
|
82
|
+
...scaffolded,
|
|
83
|
+
...pageSitemapRoutes(servedPageRoutes),
|
|
84
|
+
]);
|
|
85
|
+
const staticUrls = staticSitemapUrls(languageCodes, defaultLanguageCode, publicOrigin, routes);
|
|
86
|
+
const productUrls = await collectProductSitemapUrls(
|
|
87
|
+
async (skip, take) => getProductSeoEntries(defaultLanguageCode, { skip, take }),
|
|
88
|
+
defaultLanguageCode,
|
|
89
|
+
publicOrigin,
|
|
90
|
+
Math.max(0, MAX_SITEMAP_URLS - staticUrls.length),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const urls = [...staticUrls, ...productUrls];
|
|
94
|
+
|
|
95
|
+
// The one place this template writes to the console, and it earns it: a
|
|
96
|
+
// catalog that outgrows a single XML sitemap would otherwise stop being
|
|
97
|
+
// submitted for indexing in complete silence, inside the feature whose whole
|
|
98
|
+
// job is getting pages indexed. There is no logger in a scaffolded storefront
|
|
99
|
+
// and no channelId to tag — only the channel token, which is a credential and
|
|
100
|
+
// must never reach a log — so the pod's stdout is the channel, and the 60s
|
|
101
|
+
// window bounds this to at most one line a minute.
|
|
102
|
+
const warning = sitemapBudgetWarning(urls.length);
|
|
103
|
+
if (warning !== null) console.warn(warning);
|
|
104
|
+
|
|
105
|
+
return urls;
|
|
106
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { connection } from 'next/server';
|
|
3
|
+
import { Suspense } from 'react';
|
|
4
|
+
|
|
5
|
+
import { AccountMessage, type AccountMessageTone } from '../../components/account/AccountMessage';
|
|
6
|
+
import { LocaleLink } from '../../components/LocaleLink';
|
|
7
|
+
import { buttonVariants } from '../../components/ui/button';
|
|
8
|
+
import { Skeleton } from '../../components/ui/skeleton';
|
|
9
|
+
import { readLinkToken, type AccountLinkOutcome } from '../../lib/account/account-link';
|
|
10
|
+
import { verifyState, type VerifyState } from '../../lib/account/verify-state';
|
|
11
|
+
import { verifyCustomerAccount } from '../../lib/forgecart';
|
|
12
|
+
import { getRequestLocale } from '../../lib/locale/request-binding';
|
|
13
|
+
import { routeMetadata } from '../../lib/seo/metadata';
|
|
14
|
+
import type { RouteSearchParams } from '../../lib/seo/noindex';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `/verify?token=…` — the page the platform's verification e-mail links to
|
|
18
|
+
* (#1472).
|
|
19
|
+
*
|
|
20
|
+
* Before this route existed, that link pointed at one platform-global host
|
|
21
|
+
* that served no such page, so every shopper who clicked it got a 404 and no
|
|
22
|
+
* account was ever verified from a mail. The link now resolves to the
|
|
23
|
+
* CHANNEL's own storefront, which is the only place that can both consume the
|
|
24
|
+
* token and tell the shopper, in the store's own voice, what happened to it.
|
|
25
|
+
*
|
|
26
|
+
* The token is consumed by the RENDER. That is what a mail link is: a GET
|
|
27
|
+
* whose entire purpose is to spend a one-shot credential, so there is no
|
|
28
|
+
* button to press first — pressing one would only add a step between the
|
|
29
|
+
* shopper and the outcome. It also means the second load of the same link is
|
|
30
|
+
* an expected state (`link-invalid`), not an error, which is why
|
|
31
|
+
* `verifyCustomerAccount` answers instead of throwing.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const PAGE_TITLE = 'Verify your e-mail';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Noindex via `NOINDEX_PATHS`, and NO alternates.
|
|
38
|
+
*
|
|
39
|
+
* `pathsByLocale` is deliberately empty rather than `staticPathsByLocale(…)`:
|
|
40
|
+
* `/verify` exists once per store (see `lib/locale/grammar.ts`), so there is no
|
|
41
|
+
* cluster of per-language addresses to claim — and building one would emit
|
|
42
|
+
* hreflang tags pointing every language at the same URL.
|
|
43
|
+
*
|
|
44
|
+
* The request's query is deliberately NOT passed either. The route rule
|
|
45
|
+
* already refuses indexing, so the only thing `searchParams` could add here is
|
|
46
|
+
* a shopper's one-shot token inside the metadata layer.
|
|
47
|
+
*/
|
|
48
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
49
|
+
const { binding, shopName, channelResolved } = await getRequestLocale();
|
|
50
|
+
return routeMetadata({
|
|
51
|
+
binding,
|
|
52
|
+
shopName,
|
|
53
|
+
channelResolved,
|
|
54
|
+
pathname: '/verify',
|
|
55
|
+
pathsByLocale: {},
|
|
56
|
+
title: PAGE_TITLE,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Every account surface stamps `data-fc-account-mode` + `data-fc-account-state`
|
|
62
|
+
* on the element it renders (the same shape as the home page's
|
|
63
|
+
* `data-fc-variant`).
|
|
64
|
+
*
|
|
65
|
+
* The states of these pages are, by construction, the hardest thing in the
|
|
66
|
+
* template to observe: each is reached once, with a credential the visit
|
|
67
|
+
* destroys. The markers make the state machine READABLE from the served HTML,
|
|
68
|
+
* which is what `tool/storefront-verify` asserts against — deliberately
|
|
69
|
+
* instead of the copy, because the copy belongs to the merchant and is the
|
|
70
|
+
* first thing a rebrand rewrites, while the state set is the contract.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/** What each outcome tells the shopper, and where it sends them next. */
|
|
74
|
+
const VERIFY_COPY: Readonly<
|
|
75
|
+
Record<VerifyState, { tone: AccountMessageTone; title: string; body: string; cta: string }>
|
|
76
|
+
> = {
|
|
77
|
+
verified: {
|
|
78
|
+
tone: 'success',
|
|
79
|
+
title: 'Your e-mail address is verified',
|
|
80
|
+
body: 'Your account is ready — you can sign in with the address you registered.',
|
|
81
|
+
cta: 'Start shopping',
|
|
82
|
+
},
|
|
83
|
+
'no-token': {
|
|
84
|
+
tone: 'warning',
|
|
85
|
+
title: 'This link is missing its verification token',
|
|
86
|
+
body: 'Open the link straight from the e-mail we sent you. It carries a one-time token this page needs, and copying only part of the address leaves it behind.',
|
|
87
|
+
cta: 'Go to the store',
|
|
88
|
+
},
|
|
89
|
+
'link-invalid': {
|
|
90
|
+
tone: 'warning',
|
|
91
|
+
title: 'This verification link is no longer valid',
|
|
92
|
+
body: 'It has either been used already or it has expired. If your account is still unverified, request a new verification e-mail and open the newest link.',
|
|
93
|
+
cta: 'Go to the store',
|
|
94
|
+
},
|
|
95
|
+
'wrong-store': {
|
|
96
|
+
tone: 'warning',
|
|
97
|
+
title: 'This link belongs to a different store',
|
|
98
|
+
body: 'The account it verifies does not shop here. Open the link from the e-mail that store sent you — every store sends its own.',
|
|
99
|
+
cta: 'Go to the store',
|
|
100
|
+
},
|
|
101
|
+
failed: {
|
|
102
|
+
tone: 'error',
|
|
103
|
+
title: 'We could not verify your e-mail address',
|
|
104
|
+
body: 'Something went wrong on our side. Please open the link again in a few minutes — and if it has stopped working by then, request a new verification e-mail.',
|
|
105
|
+
cta: 'Go to the store',
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export default function VerifyPage({ searchParams }: { searchParams: Promise<RouteSearchParams> }) {
|
|
110
|
+
return (
|
|
111
|
+
<div className="mx-auto max-w-xl space-y-6">
|
|
112
|
+
<h1 className="text-2xl font-bold tracking-tight">{PAGE_TITLE}</h1>
|
|
113
|
+
<Suspense fallback={<VerifyRouteSkeleton />}>
|
|
114
|
+
<VerifyOutcome searchParams={searchParams} />
|
|
115
|
+
</Suspense>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The request-time hole: the token is read and spent here, inside the Suspense
|
|
122
|
+
* boundary, so the shell paints while the API is asked.
|
|
123
|
+
*
|
|
124
|
+
* The `searchParams` promise is passed DOWN rather than awaited in the shell —
|
|
125
|
+
* awaiting it above would pull the token into the static part of the render for
|
|
126
|
+
* no benefit, since nothing above this component reads it.
|
|
127
|
+
*/
|
|
128
|
+
async function VerifyOutcome({ searchParams }: { searchParams: Promise<RouteSearchParams> }) {
|
|
129
|
+
await connection();
|
|
130
|
+
const [params, { binding }] = await Promise.all([searchParams, getRequestLocale()]);
|
|
131
|
+
|
|
132
|
+
const token = readLinkToken(params);
|
|
133
|
+
const outcome: AccountLinkOutcome =
|
|
134
|
+
token === null ? { kind: 'no-token' } : await verifyCustomerAccount(token);
|
|
135
|
+
const state = verifyState(outcome);
|
|
136
|
+
const copy = VERIFY_COPY[state];
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="space-y-6" data-fc-account-mode="verify" data-fc-account-state={state}>
|
|
140
|
+
<AccountMessage tone={copy.tone} title={copy.title}>
|
|
141
|
+
{copy.body}
|
|
142
|
+
</AccountMessage>
|
|
143
|
+
<LocaleLink href="/" locale={binding} className={buttonVariants()}>
|
|
144
|
+
{copy.cta}
|
|
145
|
+
</LocaleLink>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function VerifyRouteSkeleton() {
|
|
151
|
+
return (
|
|
152
|
+
<div className="space-y-6" aria-busy="true" aria-label="Verifying your e-mail address">
|
|
153
|
+
<Skeleton className="h-20 w-full" />
|
|
154
|
+
<Skeleton className="h-12 w-44" />
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { LocaleBinding } from '../lib/locale/localized-path';
|
|
4
|
+
import { LocaleLink } from './LocaleLink';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
|
|
7
|
+
import { Alert, AlertDescription } from './ui/alert';
|
|
8
|
+
import { Badge } from './ui/badge';
|
|
9
|
+
import { Button, buttonVariants } from './ui/button';
|
|
10
|
+
import { Card, CardContent } from './ui/card';
|
|
11
|
+
import { Input } from './ui/input';
|
|
12
|
+
import { Skeleton } from './ui/skeleton';
|
|
13
|
+
import { cn } from './ui/utils';
|
|
14
|
+
import { getAssetAlt } from '../lib/asset-alt';
|
|
15
|
+
import { useCart } from '../lib/cart-context';
|
|
16
|
+
import type { SellingPlan, SellingPlanGroup } from '../lib/forgecart';
|
|
17
|
+
import { formatPrice, getPlanCadenceLabel, getPlanSavingsLabel } from '../lib/format';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The interactive cart, rendered off the live order from `useCart()` —
|
|
21
|
+
* Blueprint `pages/checkout-flow` cart shape: line items on the left,
|
|
22
|
+
* a sticky order-summary rail (coupon, totals, checkout) on the right.
|
|
23
|
+
*
|
|
24
|
+
* `channelGroups` are the channel-wide subscription selling plans, fetched
|
|
25
|
+
* server-side by the cart route and passed in (the shop client is server-only,
|
|
26
|
+
* so a client component can't fetch them itself). When present, a "Subscribe to
|
|
27
|
+
* your whole order" box lets the customer put the entire order on a recurring
|
|
28
|
+
* plan; the chosen plan is reflected by the order's `sellingPlanId`. Lines that
|
|
29
|
+
* carry their own `sellingPlanId` (per-line subscriptions added from a product
|
|
30
|
+
* page) get a small "Subscription" label.
|
|
31
|
+
*/
|
|
32
|
+
export function CartView({
|
|
33
|
+
channelGroups,
|
|
34
|
+
locale,
|
|
35
|
+
}: {
|
|
36
|
+
channelGroups: SellingPlanGroup[];
|
|
37
|
+
/** Threaded from the server page — a client component cannot read it. */
|
|
38
|
+
locale: LocaleBinding;
|
|
39
|
+
}) {
|
|
40
|
+
const {
|
|
41
|
+
cart,
|
|
42
|
+
itemCount,
|
|
43
|
+
subtotal,
|
|
44
|
+
hydrated,
|
|
45
|
+
setQuantity,
|
|
46
|
+
remove,
|
|
47
|
+
setSellingPlan,
|
|
48
|
+
applyCoupon,
|
|
49
|
+
removeCoupon,
|
|
50
|
+
pending,
|
|
51
|
+
error,
|
|
52
|
+
clearError,
|
|
53
|
+
} = useCart();
|
|
54
|
+
const [couponInput, setCouponInput] = useState('');
|
|
55
|
+
const lines = cart?.lines ?? [];
|
|
56
|
+
|
|
57
|
+
// The cart hydrates over the shopper's websocket after mount (PPR shell) —
|
|
58
|
+
// a skeleton, never a false "empty cart" flash.
|
|
59
|
+
if (!hydrated) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="space-y-8" aria-busy="true" aria-label="Loading your cart">
|
|
62
|
+
<h1 className="text-3xl font-bold tracking-tight">Your cart</h1>
|
|
63
|
+
<div className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
|
64
|
+
<Skeleton className="h-64 w-full" />
|
|
65
|
+
<Skeleton className="h-80 w-full" />
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (itemCount === 0) {
|
|
72
|
+
return (
|
|
73
|
+
<div className="mx-auto max-w-md space-y-4 py-16 text-center">
|
|
74
|
+
<h1 className="text-3xl font-bold tracking-tight">Your cart</h1>
|
|
75
|
+
<p className="text-muted-foreground">
|
|
76
|
+
Your cart is empty — it starts the moment you add your first item.
|
|
77
|
+
</p>
|
|
78
|
+
<LocaleLink
|
|
79
|
+
href="/products"
|
|
80
|
+
locale={locale}
|
|
81
|
+
data-fc-track="cart-browse-products"
|
|
82
|
+
className={buttonVariants()}
|
|
83
|
+
>
|
|
84
|
+
Browse products
|
|
85
|
+
</LocaleLink>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Channel-wide plans (enabled only), flattened across the channel groups.
|
|
91
|
+
const channelPlans: SellingPlan[] = channelGroups
|
|
92
|
+
.flatMap((g) => g.plans)
|
|
93
|
+
.filter((p) => p.enabled);
|
|
94
|
+
const activePlanId = cart?.sellingPlanId ?? null;
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="space-y-8">
|
|
98
|
+
<h1 className="text-3xl font-bold tracking-tight">Your cart</h1>
|
|
99
|
+
|
|
100
|
+
<div className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
|
101
|
+
<div className="space-y-6">
|
|
102
|
+
<ul className="divide-y rounded-xl border bg-card text-card-foreground">
|
|
103
|
+
{lines.map((line) => (
|
|
104
|
+
<li
|
|
105
|
+
key={line.id}
|
|
106
|
+
className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-4 p-4 sm:grid-cols-[auto_minmax(0,1fr)_auto]"
|
|
107
|
+
>
|
|
108
|
+
<div className="size-16 overflow-hidden rounded-md bg-muted">
|
|
109
|
+
{line.featuredAsset?.preview ? (
|
|
110
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
111
|
+
<img
|
|
112
|
+
src={line.featuredAsset.preview}
|
|
113
|
+
alt={getAssetAlt(line.featuredAsset)}
|
|
114
|
+
className="h-full w-full object-cover"
|
|
115
|
+
/>
|
|
116
|
+
) : null}
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
<div className="min-w-0">
|
|
120
|
+
<p className="truncate font-medium">{line.productVariant.name}</p>
|
|
121
|
+
<p className="text-sm text-muted-foreground">
|
|
122
|
+
{formatPrice(line.unitPriceWithTax)} each
|
|
123
|
+
</p>
|
|
124
|
+
{line.sellingPlanId && (
|
|
125
|
+
<Badge variant="secondary" className="mt-1">
|
|
126
|
+
Subscription
|
|
127
|
+
</Badge>
|
|
128
|
+
)}
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<div className="col-span-2 flex items-center justify-end gap-3 sm:col-span-1">
|
|
132
|
+
<label className="sr-only" htmlFor={`qty-${line.id}`}>
|
|
133
|
+
Quantity
|
|
134
|
+
</label>
|
|
135
|
+
<Input
|
|
136
|
+
id={`qty-${line.id}`}
|
|
137
|
+
type="number"
|
|
138
|
+
min={0}
|
|
139
|
+
value={line.quantity}
|
|
140
|
+
disabled={pending}
|
|
141
|
+
onChange={(e) => {
|
|
142
|
+
// 0 is a real value — updateLine removes the line for it.
|
|
143
|
+
const parsed = Number.parseInt(e.target.value, 10);
|
|
144
|
+
setQuantity(line.id, Number.isNaN(parsed) ? line.quantity : parsed);
|
|
145
|
+
}}
|
|
146
|
+
className="h-8 w-16"
|
|
147
|
+
/>
|
|
148
|
+
<div className="w-20 text-right font-medium tabular-nums">
|
|
149
|
+
{formatPrice(line.linePriceWithTax)}
|
|
150
|
+
</div>
|
|
151
|
+
<Button
|
|
152
|
+
type="button"
|
|
153
|
+
variant="link"
|
|
154
|
+
size="sm"
|
|
155
|
+
onClick={() => remove(line.id)}
|
|
156
|
+
disabled={pending}
|
|
157
|
+
className="h-auto px-0 text-xs text-muted-foreground hover:text-destructive"
|
|
158
|
+
aria-label={`Remove ${line.productVariant.name}`}
|
|
159
|
+
>
|
|
160
|
+
Remove
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
</li>
|
|
164
|
+
))}
|
|
165
|
+
</ul>
|
|
166
|
+
|
|
167
|
+
{channelPlans.length > 0 && (
|
|
168
|
+
<Card className="gap-0 py-0">
|
|
169
|
+
<CardContent className="flex flex-col gap-3 p-5">
|
|
170
|
+
<h2 className="text-base font-semibold">Subscribe to your whole order</h2>
|
|
171
|
+
<div className="flex flex-col gap-2">
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={() => setSellingPlan(null)}
|
|
175
|
+
disabled={pending}
|
|
176
|
+
aria-pressed={activePlanId === null}
|
|
177
|
+
className={cn(
|
|
178
|
+
'rounded-md border px-3 py-2 text-left text-sm transition-colors disabled:opacity-50',
|
|
179
|
+
activePlanId === null
|
|
180
|
+
? 'border-primary ring-1 ring-primary'
|
|
181
|
+
: 'hover:border-primary',
|
|
182
|
+
)}
|
|
183
|
+
>
|
|
184
|
+
<span className="font-medium">No, one-time</span>
|
|
185
|
+
</button>
|
|
186
|
+
{channelPlans.map((plan) => {
|
|
187
|
+
const savings = getPlanSavingsLabel(plan);
|
|
188
|
+
const hint = [
|
|
189
|
+
savings,
|
|
190
|
+
plan.trialDays > 0 ? `${plan.trialDays}-day free trial` : null,
|
|
191
|
+
]
|
|
192
|
+
.filter(Boolean)
|
|
193
|
+
.join(' · ');
|
|
194
|
+
const active = activePlanId === plan.id;
|
|
195
|
+
return (
|
|
196
|
+
<button
|
|
197
|
+
key={plan.id}
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={() => setSellingPlan(plan.id)}
|
|
200
|
+
disabled={pending}
|
|
201
|
+
data-fc-track="cart-subscribe-order"
|
|
202
|
+
aria-pressed={active}
|
|
203
|
+
className={cn(
|
|
204
|
+
'rounded-md border px-3 py-2 text-left text-sm transition-colors disabled:opacity-50',
|
|
205
|
+
active ? 'border-primary ring-1 ring-primary' : 'hover:border-primary',
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
<span className="block font-medium">
|
|
209
|
+
Subscribe — {getPlanCadenceLabel(plan)}
|
|
210
|
+
</span>
|
|
211
|
+
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
|
|
212
|
+
</button>
|
|
213
|
+
);
|
|
214
|
+
})}
|
|
215
|
+
</div>
|
|
216
|
+
</CardContent>
|
|
217
|
+
</Card>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<aside className="lg:sticky lg:top-24" aria-label="Order summary">
|
|
222
|
+
<Card className="gap-0 py-0">
|
|
223
|
+
<CardContent className="flex flex-col gap-4 p-5">
|
|
224
|
+
<h2 className="text-base font-semibold">Order summary</h2>
|
|
225
|
+
|
|
226
|
+
<form
|
|
227
|
+
className="flex gap-2"
|
|
228
|
+
onSubmit={(e) => {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
const code = couponInput.trim();
|
|
231
|
+
if (!code) return;
|
|
232
|
+
applyCoupon(code);
|
|
233
|
+
setCouponInput('');
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<label className="sr-only" htmlFor="coupon-code">
|
|
237
|
+
Coupon code
|
|
238
|
+
</label>
|
|
239
|
+
<Input
|
|
240
|
+
id="coupon-code"
|
|
241
|
+
type="text"
|
|
242
|
+
value={couponInput}
|
|
243
|
+
placeholder="Coupon code"
|
|
244
|
+
disabled={pending}
|
|
245
|
+
onChange={(e) => setCouponInput(e.target.value)}
|
|
246
|
+
className="h-8 flex-1"
|
|
247
|
+
/>
|
|
248
|
+
<Button
|
|
249
|
+
type="submit"
|
|
250
|
+
variant="outline"
|
|
251
|
+
size="sm"
|
|
252
|
+
disabled={pending}
|
|
253
|
+
data-fc-track="cart-apply-coupon"
|
|
254
|
+
>
|
|
255
|
+
Apply
|
|
256
|
+
</Button>
|
|
257
|
+
</form>
|
|
258
|
+
|
|
259
|
+
{(cart?.couponCodes.length ?? 0) > 0 && (
|
|
260
|
+
<div className="flex flex-wrap gap-2">
|
|
261
|
+
{cart!.couponCodes.map((code) => (
|
|
262
|
+
<Badge key={code} variant="secondary">
|
|
263
|
+
{code}
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
onClick={() => removeCoupon(code)}
|
|
267
|
+
disabled={pending}
|
|
268
|
+
aria-label={`Remove coupon ${code}`}
|
|
269
|
+
>
|
|
270
|
+
✕
|
|
271
|
+
</button>
|
|
272
|
+
</Badge>
|
|
273
|
+
))}
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
{error && (
|
|
278
|
+
<Alert
|
|
279
|
+
role="alert"
|
|
280
|
+
variant="destructive"
|
|
281
|
+
className="flex items-center justify-between gap-3"
|
|
282
|
+
>
|
|
283
|
+
<AlertDescription>{error.message ?? error.code}</AlertDescription>
|
|
284
|
+
<Button
|
|
285
|
+
type="button"
|
|
286
|
+
variant="ghost"
|
|
287
|
+
size="sm"
|
|
288
|
+
className="h-7 px-2"
|
|
289
|
+
onClick={clearError}
|
|
290
|
+
>
|
|
291
|
+
Dismiss
|
|
292
|
+
</Button>
|
|
293
|
+
</Alert>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
<div className="space-y-1 border-t pt-3">
|
|
297
|
+
<div className="flex items-center justify-between">
|
|
298
|
+
<span className="text-sm text-muted-foreground">
|
|
299
|
+
Subtotal ({itemCount} items)
|
|
300
|
+
</span>
|
|
301
|
+
<span className="font-medium tabular-nums">{formatPrice(subtotal)}</span>
|
|
302
|
+
</div>
|
|
303
|
+
{(cart?.discounts ?? []).map((discount) => (
|
|
304
|
+
<div key={discount.description} className="flex items-center justify-between">
|
|
305
|
+
<span className="text-sm text-muted-foreground">{discount.description}</span>
|
|
306
|
+
<span className="text-sm tabular-nums">
|
|
307
|
+
−{formatPrice(Math.abs(discount.amountWithTax))}
|
|
308
|
+
</span>
|
|
309
|
+
</div>
|
|
310
|
+
))}
|
|
311
|
+
<div className="flex items-center justify-between pt-1">
|
|
312
|
+
<span className="text-sm text-muted-foreground">Total</span>
|
|
313
|
+
<span className="text-lg font-semibold tabular-nums">
|
|
314
|
+
{formatPrice(cart?.totalWithTax ?? subtotal)}
|
|
315
|
+
</span>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<LocaleLink
|
|
320
|
+
href="/checkout"
|
|
321
|
+
locale={locale}
|
|
322
|
+
data-fc-track="cart-checkout"
|
|
323
|
+
className={cn(buttonVariants(), 'w-full')}
|
|
324
|
+
>
|
|
325
|
+
Checkout
|
|
326
|
+
</LocaleLink>
|
|
327
|
+
</CardContent>
|
|
328
|
+
</Card>
|
|
329
|
+
</aside>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
}
|