@forgecart/cli 2.202610052310.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,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
+ }