@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.
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,73 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import type { LocaleBinding } from '../lib/locale/localized-path';
6
+ import { useCart } from '../lib/cart-context';
7
+ import { LanguageSwitcher } from './LanguageSwitcher';
8
+ import { LocaleLink } from './LocaleLink';
9
+ import { Badge } from './ui/badge';
10
+ import { buttonVariants } from './ui/button';
11
+
12
+ export interface HeaderProps {
13
+ /**
14
+ * The request's locale, threaded down from the layout. A client component
15
+ * cannot read it: `usePathname()` returns the REWRITTEN path under the
16
+ * locale rewrite, so deriving it here would emit English links on every
17
+ * German page.
18
+ */
19
+ locale: LocaleBinding;
20
+ /** The channel's languages, for the switcher. */
21
+ languageCodes: readonly string[];
22
+ }
23
+
24
+ /** Top navigation: Home / Products / Cart, with a live cart item count. */
25
+ export function Header({ locale, languageCodes }: HeaderProps) {
26
+ const { itemCount } = useCart();
27
+
28
+ // The count is seeded from the server's `initialCart`, but a cookie set after
29
+ // that render can leave the client with a different count — rendering the
30
+ // badge on the server would then hydration-mismatch. Gate it on a
31
+ // post-hydration mount flag so the server emits no badge; it appears with the
32
+ // live count only once the client takes over.
33
+ const [mounted, setMounted] = useState(false);
34
+ useEffect(() => {
35
+ setMounted(true);
36
+ }, []);
37
+
38
+ return (
39
+ <header className="sticky top-0 z-30 border-b bg-card">
40
+ <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
41
+ <LocaleLink href="/" locale={locale} className="text-lg font-bold tracking-tight">
42
+ Storefront
43
+ </LocaleLink>
44
+ <nav aria-label="Store" className="flex items-center gap-1 text-sm font-medium">
45
+ <LocaleLink
46
+ href="/"
47
+ locale={locale}
48
+ className={buttonVariants({ variant: 'ghost', size: 'sm' })}
49
+ >
50
+ Home
51
+ </LocaleLink>
52
+ <LocaleLink
53
+ href="/products"
54
+ locale={locale}
55
+ className={buttonVariants({ variant: 'ghost', size: 'sm' })}
56
+ >
57
+ Products
58
+ </LocaleLink>
59
+ <LocaleLink
60
+ href="/cart"
61
+ locale={locale}
62
+ data-fc-track="header-cart"
63
+ className={buttonVariants({ variant: 'ghost', size: 'sm' })}
64
+ >
65
+ Cart
66
+ {mounted && itemCount > 0 && <Badge className="font-semibold">{itemCount}</Badge>}
67
+ </LocaleLink>
68
+ <LanguageSwitcher locale={locale} languageCodes={languageCodes} />
69
+ </nav>
70
+ </div>
71
+ </header>
72
+ );
73
+ }
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { usePathname } from 'next/navigation';
4
+
5
+ import { switchedLocalePath } from '../lib/locale/localized-path';
6
+ import type { LocaleBinding } from '../lib/locale/localized-path';
7
+ import { buttonVariants } from './ui/button';
8
+ import { cn } from './ui/utils';
9
+
10
+ /**
11
+ * The language switcher (#1346, W1-8 / S2).
12
+ *
13
+ * Deliberately plain `<a>` elements rather than `<LocaleLink>` / `next/link`,
14
+ * for two reasons that point the same way:
15
+ *
16
+ * 1. **A locale switch must produce a NEW DOCUMENT.** Under the middleware
17
+ * rewrite `/products` and `/de/products` render the SAME route, so a
18
+ * client-side navigation between them changes no route segment and the
19
+ * shared root layout is not re-run — `<html lang>` would keep the language
20
+ * the shopper just left. A document load re-enters the middleware and
21
+ * re-resolves the locale from the URL, which makes the new `lang`, the new
22
+ * `Content-Language` and the new session language unconditional.
23
+ * 2. **These anchors are the discovery bridge between clusters.** Every other
24
+ * link stays inside its own locale by construction, so without a real
25
+ * crawlable `<a>` here nothing on the English site would ever point at the
26
+ * German one. (The head-level `<link rel="alternate" hreflang>` annotation
27
+ * that formalises the relationship is #1347's; `hrefLang` on the anchor is
28
+ * just the honest description of where this particular link goes.)
29
+ *
30
+ * **Why `usePathname()` here, when `LocaleLink` forbids it.** What that hook
31
+ * cannot give you is the LOCALE: the rewrite strips the prefix, so the path it
32
+ * returns names the route, not the language — which is why the binding arrives
33
+ * as a prop. The PATH is the one thing it reports correctly, and it does so on
34
+ * both branches: on a prefixed URL it returns the rewritten (unprefixed) route,
35
+ * and on an unprefixed URL it returns the path itself. Either form is safe
36
+ * input, because the grammar strips any prefix before applying the target's —
37
+ * `de → fr` yields `/fr/...`, never `/fr/de/...`, whichever form it saw.
38
+ *
39
+ * The alternative — carrying the path in a middleware request header — was
40
+ * rejected: the header is only authored on the REWRITE branch, so it is absent
41
+ * on exactly the unprefixed pages this switcher must work on, and populating it
42
+ * there would force `NextResponse.next({request})` (a full copy of the inbound
43
+ * header block) onto every request that currently takes the fast path.
44
+ */
45
+
46
+ export interface LanguageSwitcherProps {
47
+ /** The request's locale binding, from the layout. Never derived here. */
48
+ locale: LocaleBinding;
49
+ /** The channel's languages, from `getRequestLocale()`. */
50
+ languageCodes: readonly string[];
51
+ }
52
+
53
+ export function LanguageSwitcher({ locale, languageCodes }: LanguageSwitcherProps) {
54
+ const pathname = usePathname();
55
+
56
+ // A one-language channel has nothing to switch between, and the grammar
57
+ // never serves it a prefix — a single dead entry would be noise.
58
+ if (languageCodes.length < 2) return null;
59
+
60
+ return (
61
+ <nav aria-label="Language" className="flex items-center gap-1">
62
+ {languageCodes.map((code) => {
63
+ // The current language is not a link to itself: a self-link is a crawl
64
+ // trap and reads as an available action when it is not one.
65
+ if (code === locale.locale) {
66
+ return (
67
+ <span
68
+ key={code}
69
+ aria-current="true"
70
+ className={cn(
71
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
72
+ 'h-7 bg-accent px-2 text-xs font-semibold text-accent-foreground uppercase',
73
+ )}
74
+ >
75
+ {code}
76
+ </span>
77
+ );
78
+ }
79
+ return (
80
+ <a
81
+ key={code}
82
+ href={switchedLocalePath(code, pathname, locale)}
83
+ hrefLang={code}
84
+ data-fc-track="language-switch"
85
+ className={cn(
86
+ buttonVariants({ variant: 'ghost', size: 'sm' }),
87
+ 'h-7 px-2 text-xs uppercase',
88
+ )}
89
+ >
90
+ {code}
91
+ </a>
92
+ );
93
+ })}
94
+ </nav>
95
+ );
96
+ }
@@ -0,0 +1,49 @@
1
+ import Link from 'next/link';
2
+ import type { ComponentProps, ReactNode } from 'react';
3
+
4
+ import { localizedPath } from '../lib/locale/localized-path';
5
+ import type { LocaleBinding } from '../lib/locale/localized-path';
6
+
7
+ /**
8
+ * An internal link that stays inside the request's locale cluster (#1346,
9
+ * W1-8 / S2).
10
+ *
11
+ * Every internal navigation goes through this instead of `next/link` directly.
12
+ * A German page whose links all point at the English cluster leaves every
13
+ * German page but the entry point unreachable by following links, so a crawler
14
+ * never discovers them and the locale is effectively unindexed.
15
+ *
16
+ * The locale arrives as a PROP and is never derived here. Under the middleware
17
+ * rewrite the browser's URL and the rendered route differ — `/de/products`
18
+ * renders the `/products` route — so `usePathname()` returns the REWRITTEN
19
+ * path, and a component that read the locale from it would emit English links
20
+ * on every German page while looking correct in review. Server components take
21
+ * the binding from `getRequestLocale()`; client components receive it from
22
+ * whichever server component renders them.
23
+ *
24
+ * `href` is the UNPREFIXED route path, always — the same string you would have
25
+ * written before the locale layer existed. Prefixing is this component's job.
26
+ */
27
+
28
+ // `locale` is omitted because `next/link` HAS a prop by that name — the Pages
29
+ // Router i18n feature, typed `string | false`. This storefront does its own
30
+ // locale routing (App Router, middleware rewrite), so that prop is never the
31
+ // one we want, and forwarding it would collide with the binding below: the
32
+ // production build fails with TS2430, which `next dev` never reports.
33
+ type ForwardedLinkProps = Omit<ComponentProps<typeof Link>, 'href' | 'children' | 'locale'>;
34
+
35
+ export interface LocaleLinkProps extends ForwardedLinkProps {
36
+ /** The unprefixed route path, e.g. `/products`. */
37
+ href: string;
38
+ /** The request's locale binding, from `getRequestLocale()`. */
39
+ locale: LocaleBinding;
40
+ children: ReactNode;
41
+ }
42
+
43
+ export function LocaleLink({ href, locale, children, ...rest }: LocaleLinkProps): ReactNode {
44
+ return (
45
+ <Link href={localizedPath(href, locale)} {...rest}>
46
+ {children}
47
+ </Link>
48
+ );
49
+ }
@@ -0,0 +1,59 @@
1
+ import type { LocaleBinding } from '../lib/locale/localized-path';
2
+ import { LocaleLink } from './LocaleLink';
3
+ import { Card, CardContent } from './ui/card';
4
+
5
+ import { getAssetAlt } from '../lib/asset-alt';
6
+ import type { Product } from '../lib/forgecart';
7
+ import { formatPrice, getStartingPrice } from '../lib/format';
8
+
9
+ /**
10
+ * A product tile linking to its detail page — a shadcn/ui card (media, then
11
+ * the card's content) inside one link. Used by the home and grid pages;
12
+ * `emphasis` renders the home grid's large lead tile with a bigger title.
13
+ */
14
+ export function ProductCard({
15
+ product,
16
+ locale,
17
+ emphasis = false,
18
+ }: {
19
+ product: Product;
20
+ /** The request's locale binding — a card's link must stay in its cluster. */
21
+ locale: LocaleBinding;
22
+ emphasis?: boolean;
23
+ }) {
24
+ const startingPrice = getStartingPrice(product);
25
+
26
+ return (
27
+ <LocaleLink
28
+ href={`/products/${product.slug}`}
29
+ locale={locale}
30
+ data-fc-track="product-card"
31
+ className="group block h-full rounded-xl"
32
+ >
33
+ <Card className="h-full gap-0 overflow-hidden py-0 transition-shadow group-hover:shadow-md">
34
+ <figure className="aspect-square overflow-hidden bg-muted">
35
+ {product.featuredAsset ? (
36
+ // Plain <img> keeps the template dependency-free of next/image config.
37
+ // eslint-disable-next-line @next/next/no-img-element
38
+ <img
39
+ src={product.featuredAsset.preview}
40
+ alt={getAssetAlt(product.featuredAsset)}
41
+ loading="lazy"
42
+ className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
43
+ />
44
+ ) : (
45
+ <div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
46
+ No image
47
+ </div>
48
+ )}
49
+ </figure>
50
+ <CardContent className="space-y-1 p-4">
51
+ <h3 className={`font-medium ${emphasis ? 'text-lg' : 'text-base'}`}>{product.name}</h3>
52
+ {startingPrice !== null && (
53
+ <p className="text-sm text-muted-foreground">From {formatPrice(startingPrice)}</p>
54
+ )}
55
+ </CardContent>
56
+ </Card>
57
+ </LocaleLink>
58
+ );
59
+ }
@@ -0,0 +1,235 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+
5
+ import { Button } from './ui/button';
6
+ import { cn } from './ui/utils';
7
+ import { useCart } from '../lib/cart-context';
8
+ import type { Product, ProductVariant, SellingPlan, SellingPlanGroup } from '../lib/forgecart';
9
+ import {
10
+ formatPrice,
11
+ getPlanCadenceLabel,
12
+ getPlanPreviewPrice,
13
+ getPlanSavingsLabel,
14
+ } from '../lib/format';
15
+
16
+ /**
17
+ * Variant picker + purchase-options + add-to-cart for the product detail page.
18
+ *
19
+ * The customer picks one value per option group (Size, Color, …); the matching
20
+ * variant drives the displayed price and what gets added to the cart — you add a
21
+ * **variant**, never the product. Single-variant products skip the selectors.
22
+ *
23
+ * When the selected variant has subscription selling plans (passed in as
24
+ * `planGroupsByVariant`, fetched server-side by the page), a "Purchase options"
25
+ * control appears above Add-to-cart: a one-time purchase plus one option per
26
+ * plan, each with a price preview and a savings/trial hint. The chosen plan id
27
+ * (or `undefined` for one-time) flows into the cart on Add. Variants with no
28
+ * plans keep the plain one-time add-to-cart path unchanged.
29
+ */
30
+ export function ProductPurchase({
31
+ product,
32
+ planGroupsByVariant = {},
33
+ }: {
34
+ product: Product;
35
+ planGroupsByVariant?: Record<string, SellingPlanGroup[]>;
36
+ }) {
37
+ const { add, pending } = useCart();
38
+ const [added, setAdded] = useState(false);
39
+
40
+ // Default selection = the first variant's option values, so a price shows
41
+ // immediately. Keyed by option group id -> selected option id.
42
+ const [selected, setSelected] = useState<Record<string, string>>(() => {
43
+ const sel: Record<string, string> = {};
44
+ for (const option of product.variants[0]?.options ?? []) {
45
+ sel[option.groupId] = option.id;
46
+ }
47
+ return sel;
48
+ });
49
+
50
+ const variant: ProductVariant | undefined = useMemo(() => {
51
+ if (product.variants.length <= 1) {
52
+ return product.variants[0];
53
+ }
54
+ return product.variants.find(
55
+ (v) => v.options.length > 0 && v.options.every((o) => selected[o.groupId] === o.id),
56
+ );
57
+ }, [product.variants, selected]);
58
+
59
+ // The selling-plan groups the *selected* variant is eligible for.
60
+ const groups: SellingPlanGroup[] = useMemo(
61
+ () => (variant ? (planGroupsByVariant[variant.id] ?? []) : []),
62
+ [planGroupsByVariant, variant],
63
+ );
64
+
65
+ // The selectable plans (enabled only), flattened across applicable groups.
66
+ const plans: SellingPlan[] = useMemo(
67
+ () => groups.flatMap((g) => g.plans).filter((p) => p.enabled),
68
+ [groups],
69
+ );
70
+
71
+ // One-time is offered unless EVERY applicable group forbids it (subscription-only).
72
+ const oneTimeAllowed = groups.length === 0 || groups.some((g) => g.oneTimePurchaseAllowed);
73
+ const hasPurchaseOptions = plans.length > 0;
74
+
75
+ // `null` = one-time purchase; otherwise the chosen plan id.
76
+ const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
77
+
78
+ // The set of valid choices changes with the variant (and thus its plans). Re-
79
+ // normalize the selection whenever that signature changes: keep a still-valid
80
+ // choice, otherwise default to one-time when allowed, else the first plan —
81
+ // never leave a stale id that is no longer offered for the current variant.
82
+ // `planIds` is a stable, primitive signature of the current plan set, so the
83
+ // effect re-runs exactly when the options (or the one-time gate) change and
84
+ // closes over no array identities.
85
+ const planIds = plans.map((p) => p.id).join(',');
86
+ useEffect(() => {
87
+ const ids = planIds ? planIds.split(',') : [];
88
+ const validIds = new Set(ids);
89
+ setSelectedPlanId((current) => {
90
+ if (current !== null && validIds.has(current)) {
91
+ return current;
92
+ }
93
+ if (oneTimeAllowed) {
94
+ return null;
95
+ }
96
+ return ids[0] ?? null;
97
+ });
98
+ }, [planIds, oneTimeAllowed]);
99
+
100
+ const selectedPlan: SellingPlan | undefined = useMemo(
101
+ () => plans.find((p) => p.id === selectedPlanId),
102
+ [plans, selectedPlanId],
103
+ );
104
+
105
+ // Price preview for the chosen option: one-time shows the variant price; a
106
+ // plan shows its policy-adjusted per-unit price.
107
+ const previewPrice = useMemo(() => {
108
+ if (!variant) {
109
+ return null;
110
+ }
111
+ if (!selectedPlan) {
112
+ return variant.priceWithTax;
113
+ }
114
+ return getPlanPreviewPrice(variant.priceWithTax, selectedPlan);
115
+ }, [variant, selectedPlan]);
116
+
117
+ const onAdd = () => {
118
+ if (!variant) {
119
+ return;
120
+ }
121
+ add(variant.id, 1, selectedPlanId ?? undefined);
122
+ setAdded(true);
123
+ window.setTimeout(() => setAdded(false), 1500);
124
+ };
125
+
126
+ return (
127
+ <div className="space-y-4">
128
+ {variant && previewPrice !== null ? (
129
+ <p className="text-xl font-semibold text-foreground">{formatPrice(previewPrice)}</p>
130
+ ) : (
131
+ <p className="text-sm text-muted-foreground">Select options to see the price.</p>
132
+ )}
133
+
134
+ {product.optionGroups.map((group) => (
135
+ <div key={group.id}>
136
+ <h3 className="mb-1 text-sm font-medium text-foreground/80">{group.name}</h3>
137
+ <div className="flex flex-wrap gap-2">
138
+ {group.options.map((opt) => {
139
+ const active = selected[group.id] === opt.id;
140
+ return (
141
+ <button
142
+ key={opt.id}
143
+ type="button"
144
+ onClick={() => setSelected((s) => ({ ...s, [group.id]: opt.id }))}
145
+ className={cn(
146
+ 'rounded-md border px-3 py-1.5 text-sm transition',
147
+ active
148
+ ? 'border-primary bg-primary text-primary-foreground'
149
+ : 'text-foreground/80 hover:border-primary',
150
+ )}
151
+ >
152
+ {opt.name}
153
+ </button>
154
+ );
155
+ })}
156
+ </div>
157
+ </div>
158
+ ))}
159
+
160
+ {variant && hasPurchaseOptions && (
161
+ <div>
162
+ <h3 className="mb-1 text-sm font-medium text-foreground/80">Purchase options</h3>
163
+ <div className="flex flex-col gap-2">
164
+ {oneTimeAllowed && (
165
+ <PurchaseOption
166
+ label="One-time purchase"
167
+ priceLabel={formatPrice(variant.priceWithTax)}
168
+ active={selectedPlanId === null}
169
+ onSelect={() => setSelectedPlanId(null)}
170
+ />
171
+ )}
172
+ {plans.map((plan) => {
173
+ const savings = getPlanSavingsLabel(plan);
174
+ const hint = [savings, plan.trialDays > 0 ? `${plan.trialDays}-day free trial` : null]
175
+ .filter(Boolean)
176
+ .join(' · ');
177
+ return (
178
+ <PurchaseOption
179
+ key={plan.id}
180
+ label={`Subscribe — ${getPlanCadenceLabel(plan)}`}
181
+ hint={hint || undefined}
182
+ priceLabel={formatPrice(getPlanPreviewPrice(variant.priceWithTax, plan))}
183
+ active={selectedPlanId === plan.id}
184
+ onSelect={() => setSelectedPlanId(plan.id)}
185
+ />
186
+ );
187
+ })}
188
+ </div>
189
+ </div>
190
+ )}
191
+
192
+ <Button
193
+ type="button"
194
+ onClick={onAdd}
195
+ disabled={!variant || pending}
196
+ data-fc-track="add-to-cart"
197
+ >
198
+ {pending ? 'Adding…' : added ? 'Added!' : variant ? 'Add to cart' : 'Unavailable'}
199
+ </Button>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ /** A single selectable purchase option (one-time or a subscription plan). */
205
+ function PurchaseOption({
206
+ label,
207
+ hint,
208
+ priceLabel,
209
+ active,
210
+ onSelect,
211
+ }: {
212
+ label: string;
213
+ hint?: string;
214
+ priceLabel: string;
215
+ active: boolean;
216
+ onSelect: () => void;
217
+ }) {
218
+ return (
219
+ <button
220
+ type="button"
221
+ onClick={onSelect}
222
+ aria-pressed={active}
223
+ className={cn(
224
+ 'flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition',
225
+ active ? 'border-primary ring-1 ring-primary' : 'hover:border-primary',
226
+ )}
227
+ >
228
+ <span className="min-w-0">
229
+ <span className="block font-medium text-foreground">{label}</span>
230
+ {hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
231
+ </span>
232
+ <span className="shrink-0 font-medium text-foreground">{priceLabel}</span>
233
+ </button>
234
+ );
235
+ }
@@ -0,0 +1,63 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
4
+
5
+ /**
6
+ * The one block every account-link state renders through (#1472).
7
+ *
8
+ * `/verify` and `/reset-password` are pages whose whole content IS an outcome:
9
+ * a shopper arrives holding a one-shot token and leaves knowing what happened
10
+ * to it. Routing all of those through one component is what keeps the
11
+ * vocabulary consistent — the same shape says "verified" and "this link has
12
+ * expired" — and keeps each page a table of copy rather than a pile of markup
13
+ * variants.
14
+ *
15
+ * Semantic tokens only, through the shadcn/ui Alert's variants (`success`,
16
+ * `warning`, …), per the theme contract in `globals.css`: a brand lives in its
17
+ * `:root` block alone, so no component here names a color.
18
+ *
19
+ * No `'use client'` and no hooks, so it renders on the server for the verify
20
+ * page and rides into the bundle when a client form renders it.
21
+ */
22
+
23
+ export type AccountMessageTone = 'success' | 'info' | 'warning' | 'error';
24
+
25
+ /**
26
+ * The Alert variant AND the live-region role per tone.
27
+ *
28
+ * `alert` is for something that has gone wrong and interrupts; `status` is a
29
+ * polite announcement. A success message that arrives with the first paint is
30
+ * not an interruption, and announcing it as one is how a screen reader ends up
31
+ * shouting "Your e-mail is verified".
32
+ */
33
+ const TONES: Readonly<
34
+ Record<
35
+ AccountMessageTone,
36
+ { variant: 'success' | 'info' | 'warning' | 'destructive'; role: 'alert' | 'status' }
37
+ >
38
+ > = {
39
+ success: { variant: 'success', role: 'status' },
40
+ info: { variant: 'info', role: 'status' },
41
+ warning: { variant: 'warning', role: 'alert' },
42
+ error: { variant: 'destructive', role: 'alert' },
43
+ };
44
+
45
+ export function AccountMessage({
46
+ tone,
47
+ title,
48
+ children,
49
+ }: {
50
+ tone: AccountMessageTone;
51
+ /** The outcome in one line — what happened, not what to do about it. */
52
+ title: string;
53
+ /** What the shopper can do next. */
54
+ children: ReactNode;
55
+ }): ReactNode {
56
+ const { variant, role } = TONES[tone];
57
+ return (
58
+ <Alert role={role} variant={variant}>
59
+ <AlertTitle className="font-semibold">{title}</AlertTitle>
60
+ <AlertDescription>{children}</AlertDescription>
61
+ </Alert>
62
+ );
63
+ }