@forgecart/cli 2.202610052143.0 → 2.202610060357.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,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
|
+
}
|