@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.
- 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,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { Button } from '../ui/button';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* In-page Stripe Payment Element — the direct-render adapter for
|
|
9
|
+
* Stripe-model providers (operator decision 2026-08-01: our own payment
|
|
10
|
+
* chrome is never iframed; the srcdoc embed provided no security boundary —
|
|
11
|
+
* it is same-origin, and PCI isolation comes from Stripe's OWN nested
|
|
12
|
+
* input iframes, which apply identically here). The page owns the button
|
|
13
|
+
* and all visible chrome, so the payment step inherits the design system;
|
|
14
|
+
* only the card inputs are Stripe's.
|
|
15
|
+
*
|
|
16
|
+
* Everything this component needs arrives as data from the shop API
|
|
17
|
+
* (`getSessionVariables` + the created session's secret) — no keys and no
|
|
18
|
+
* provider configuration live in the template source. Providers without an
|
|
19
|
+
* in-page adapter keep the hosted-template embed (`PaymentFormEmbed`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Minimal structural surface of Stripe.js v3 — the template ships no SDK dep. */
|
|
23
|
+
interface StripeElementsHandle {
|
|
24
|
+
create: (kind: 'payment') => { mount: (node: HTMLElement) => void; unmount: () => void };
|
|
25
|
+
}
|
|
26
|
+
interface StripeHandle {
|
|
27
|
+
elements: (options: { clientSecret: string }) => StripeElementsHandle;
|
|
28
|
+
confirmPayment: (options: {
|
|
29
|
+
elements: StripeElementsHandle;
|
|
30
|
+
redirect: 'if_required';
|
|
31
|
+
}) => Promise<{
|
|
32
|
+
error?: { message?: string };
|
|
33
|
+
paymentIntent?: { id: string; status: string };
|
|
34
|
+
}>;
|
|
35
|
+
}
|
|
36
|
+
declare global {
|
|
37
|
+
interface Window {
|
|
38
|
+
Stripe?: (publishableKey: string) => StripeHandle;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Load the provider script once; concurrent callers share the same promise. */
|
|
43
|
+
let scriptPromise: Promise<void> | null = null;
|
|
44
|
+
function loadProviderScript(scriptUrl: string): Promise<void> {
|
|
45
|
+
if (window.Stripe) return Promise.resolve();
|
|
46
|
+
scriptPromise ??= new Promise<void>((resolve, reject) => {
|
|
47
|
+
const script = document.createElement('script');
|
|
48
|
+
script.src = scriptUrl;
|
|
49
|
+
script.async = true;
|
|
50
|
+
script.onload = () => resolve();
|
|
51
|
+
script.onerror = () => {
|
|
52
|
+
scriptPromise = null;
|
|
53
|
+
reject(new Error('Payment provider script failed to load'));
|
|
54
|
+
};
|
|
55
|
+
document.head.appendChild(script);
|
|
56
|
+
});
|
|
57
|
+
return scriptPromise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function PaymentElementForm({
|
|
61
|
+
publishableKey,
|
|
62
|
+
scriptUrl,
|
|
63
|
+
clientSecret,
|
|
64
|
+
payLabel,
|
|
65
|
+
onSuccess,
|
|
66
|
+
onError,
|
|
67
|
+
}: {
|
|
68
|
+
publishableKey: string;
|
|
69
|
+
scriptUrl: string;
|
|
70
|
+
clientSecret: string;
|
|
71
|
+
/** Themed button label, e.g. `Pay €42.00` — the PAGE owns the chrome. */
|
|
72
|
+
payLabel: string;
|
|
73
|
+
onSuccess: (status: string, paymentIntentId: string) => void;
|
|
74
|
+
onError: (message: string) => void;
|
|
75
|
+
}) {
|
|
76
|
+
const mountRef = useRef<HTMLDivElement | null>(null);
|
|
77
|
+
const stripeRef = useRef<StripeHandle | null>(null);
|
|
78
|
+
const elementsRef = useRef<StripeElementsHandle | null>(null);
|
|
79
|
+
const [mounted, setMounted] = useState(false);
|
|
80
|
+
const [paying, setPaying] = useState(false);
|
|
81
|
+
// Callbacks ride a ref so the mount effect binds once per secret.
|
|
82
|
+
const handlersRef = useRef({ onError });
|
|
83
|
+
handlersRef.current = { onError };
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
let cancelled = false;
|
|
87
|
+
let element: { unmount: () => void } | null = null;
|
|
88
|
+
void loadProviderScript(scriptUrl)
|
|
89
|
+
.then(() => {
|
|
90
|
+
if (cancelled || !window.Stripe || !mountRef.current) return;
|
|
91
|
+
const stripe = window.Stripe(publishableKey);
|
|
92
|
+
const elements = stripe.elements({ clientSecret });
|
|
93
|
+
const paymentElement = elements.create('payment');
|
|
94
|
+
paymentElement.mount(mountRef.current);
|
|
95
|
+
stripeRef.current = stripe;
|
|
96
|
+
elementsRef.current = elements;
|
|
97
|
+
element = paymentElement;
|
|
98
|
+
setMounted(true);
|
|
99
|
+
})
|
|
100
|
+
.catch((error: Error) => {
|
|
101
|
+
if (!cancelled) handlersRef.current.onError(error.message);
|
|
102
|
+
});
|
|
103
|
+
return () => {
|
|
104
|
+
cancelled = true;
|
|
105
|
+
element?.unmount();
|
|
106
|
+
};
|
|
107
|
+
}, [scriptUrl, publishableKey, clientSecret]);
|
|
108
|
+
|
|
109
|
+
async function pay(): Promise<void> {
|
|
110
|
+
const stripe = stripeRef.current;
|
|
111
|
+
const elements = elementsRef.current;
|
|
112
|
+
if (!stripe || !elements || paying) return;
|
|
113
|
+
setPaying(true);
|
|
114
|
+
const result = await stripe.confirmPayment({ elements, redirect: 'if_required' });
|
|
115
|
+
setPaying(false);
|
|
116
|
+
if (result.error) {
|
|
117
|
+
onError(result.error.message ?? 'Payment failed');
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (result.paymentIntent) {
|
|
121
|
+
onSuccess(result.paymentIntent.status, result.paymentIntent.id);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div className="space-y-4">
|
|
127
|
+
{!mounted && <p className="text-sm text-muted-foreground">Loading payment form…</p>}
|
|
128
|
+
<div ref={mountRef} />
|
|
129
|
+
<Button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => void pay()}
|
|
132
|
+
disabled={!mounted || paying}
|
|
133
|
+
data-fc-track="checkout-pay"
|
|
134
|
+
className="w-full"
|
|
135
|
+
>
|
|
136
|
+
{paying ? 'Processing…' : payLabel}
|
|
137
|
+
</Button>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Provider-agnostic host for the shop API's hosted payment form
|
|
7
|
+
* (`PaymentFormTemplate.html` — Stripe, Mollie, … the template carries all
|
|
8
|
+
* provider code; this component contains none).
|
|
9
|
+
*
|
|
10
|
+
* The handshake is the #865 contract, extended with in-band secret delivery:
|
|
11
|
+
* the hosted form posts `iframe-ready`, `payment-success` and
|
|
12
|
+
* `payment-error` to its embedder, and the embedder answers `iframe-ready`
|
|
13
|
+
* by posting `client-secret` back INTO the frame — the session's secret
|
|
14
|
+
* never rides the rendered HTML (which may be logged or cached
|
|
15
|
+
* server-side); the provider template mounts its payment element only once
|
|
16
|
+
* the secret arrives. Every message in both directions is pinned to the
|
|
17
|
+
* page's own origin: the srcdoc iframe is same-origin with this page (no
|
|
18
|
+
* sandbox), so legitimate traffic carries `window.location.origin` — any
|
|
19
|
+
* other origin (another iframe, an extension, a foreign embed) is ignored.
|
|
20
|
+
*/
|
|
21
|
+
export interface PaymentFormMessage {
|
|
22
|
+
type: 'iframe-ready' | 'payment-success' | 'payment-error';
|
|
23
|
+
error?: string;
|
|
24
|
+
paymentIntentId?: string;
|
|
25
|
+
status?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function PaymentFormEmbed({
|
|
29
|
+
html,
|
|
30
|
+
clientSecret,
|
|
31
|
+
onSuccess,
|
|
32
|
+
onError,
|
|
33
|
+
}: {
|
|
34
|
+
html: string;
|
|
35
|
+
/** The payment session's secret, delivered to the frame post-`iframe-ready`. */
|
|
36
|
+
clientSecret?: string;
|
|
37
|
+
onSuccess: (message: PaymentFormMessage) => void;
|
|
38
|
+
onError: (message: string) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const [ready, setReady] = useState(false);
|
|
41
|
+
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
|
42
|
+
// Callbacks ride a ref so the message listener binds once per html.
|
|
43
|
+
const handlersRef = useRef({ onSuccess, onError });
|
|
44
|
+
handlersRef.current = { onSuccess, onError };
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
function onMessage(event: MessageEvent<PaymentFormMessage>): void {
|
|
48
|
+
if (event.origin !== window.location.origin) return;
|
|
49
|
+
const data = event.data;
|
|
50
|
+
if (!data || typeof data !== 'object') return;
|
|
51
|
+
if (data.type === 'iframe-ready') {
|
|
52
|
+
setReady(true);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (data.type === 'payment-success') {
|
|
56
|
+
handlersRef.current.onSuccess(data);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (data.type === 'payment-error') {
|
|
60
|
+
handlersRef.current.onError(data.error ?? 'Payment failed');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
window.addEventListener('message', onMessage);
|
|
64
|
+
return () => window.removeEventListener('message', onMessage);
|
|
65
|
+
}, [html]);
|
|
66
|
+
|
|
67
|
+
// Deliver the secret once the frame is listening. Embedded-confirm
|
|
68
|
+
// templates (Stripe's Payment Element) mount on receipt; templates with no
|
|
69
|
+
// client-side secret (redirect models) simply never consume the message.
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (!ready || !clientSecret) return;
|
|
72
|
+
frameRef.current?.contentWindow?.postMessage(
|
|
73
|
+
{ type: 'client-secret', clientSecret },
|
|
74
|
+
window.location.origin,
|
|
75
|
+
);
|
|
76
|
+
}, [ready, clientSecret]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="space-y-2">
|
|
80
|
+
{!ready && <p className="text-sm text-muted-foreground">Loading payment form…</p>}
|
|
81
|
+
<iframe
|
|
82
|
+
ref={frameRef}
|
|
83
|
+
title="Payment form"
|
|
84
|
+
srcDoc={html}
|
|
85
|
+
className="min-h-96 w-full rounded-md border bg-card"
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ExtractedError } from '@forgecart/sdk/shop';
|
|
4
|
+
|
|
5
|
+
import type { ShippingRateGroup } from '../../lib/forgecart';
|
|
6
|
+
import { formatPrice } from '../../lib/format';
|
|
7
|
+
import { Alert, AlertDescription } from '../ui/alert';
|
|
8
|
+
import { Button } from '../ui/button';
|
|
9
|
+
import { Card } from '../ui/card';
|
|
10
|
+
import { cn } from '../ui/utils';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Shipping-rate selection: one card group per shipment (rates arrive grouped
|
|
14
|
+
* by stock location), radio-cards per rate, per-group `lastRateErrors`
|
|
15
|
+
* alerts (a refusing carrier never hides its siblings' quotes). The CUSTOMER
|
|
16
|
+
* picks — nothing auto-selects; the ArrangingPayment transition requires a
|
|
17
|
+
* selection in every group (#863's `hasSelectedRates` gate).
|
|
18
|
+
*/
|
|
19
|
+
export function RatesStep({
|
|
20
|
+
groups,
|
|
21
|
+
onSelect,
|
|
22
|
+
onContinue,
|
|
23
|
+
pending,
|
|
24
|
+
refreshing,
|
|
25
|
+
error,
|
|
26
|
+
}: {
|
|
27
|
+
groups: ShippingRateGroup[];
|
|
28
|
+
onSelect: (rateGroupId: string, rateId: string) => void;
|
|
29
|
+
onContinue: () => void;
|
|
30
|
+
pending: boolean;
|
|
31
|
+
refreshing: boolean;
|
|
32
|
+
error: ExtractedError | null;
|
|
33
|
+
}) {
|
|
34
|
+
if (refreshing) {
|
|
35
|
+
return <p className="text-sm text-muted-foreground">Fetching shipping rates…</p>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const allSelected =
|
|
39
|
+
groups.length > 0 && groups.every((group) => group.rates.some((rate) => rate.selected));
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="space-y-4">
|
|
43
|
+
{groups.length === 0 && (
|
|
44
|
+
<Alert role="alert" variant="warning">
|
|
45
|
+
<AlertDescription>No shipping rates are available for this address yet.</AlertDescription>
|
|
46
|
+
</Alert>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
{groups.map((group, index) => (
|
|
50
|
+
<Card key={group.id} className="gap-2 p-4">
|
|
51
|
+
<h3 className="text-sm font-medium text-foreground/80">
|
|
52
|
+
{groups.length > 1 ? `Shipment ${index + 1}` : 'Shipping method'}
|
|
53
|
+
{group.stockLocationName ? ` — ships from ${group.stockLocationName}` : ''}
|
|
54
|
+
</h3>
|
|
55
|
+
|
|
56
|
+
{group.lastRateErrors.length > 0 && (
|
|
57
|
+
<Alert role="alert" variant="warning">
|
|
58
|
+
<AlertDescription>
|
|
59
|
+
{group.lastRateErrors
|
|
60
|
+
.map((failure) => `${failure.providerName}: quotes unavailable`)
|
|
61
|
+
.join(' · ')}
|
|
62
|
+
</AlertDescription>
|
|
63
|
+
</Alert>
|
|
64
|
+
)}
|
|
65
|
+
|
|
66
|
+
<div className="flex flex-col gap-2">
|
|
67
|
+
{group.rates.map((rate) => (
|
|
68
|
+
<button
|
|
69
|
+
key={rate.id}
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => onSelect(group.id, rate.id)}
|
|
72
|
+
disabled={pending}
|
|
73
|
+
aria-pressed={rate.selected}
|
|
74
|
+
data-fc-track="checkout-select-rate"
|
|
75
|
+
className={cn(
|
|
76
|
+
'flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50',
|
|
77
|
+
rate.selected ? 'border-primary ring-1 ring-primary' : 'hover:border-primary',
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<span className="min-w-0">
|
|
81
|
+
<span className="block font-medium text-foreground">
|
|
82
|
+
{rate.carrier} {rate.service}
|
|
83
|
+
</span>
|
|
84
|
+
{rate.estimatedDays !== null && rate.estimatedDays !== undefined && (
|
|
85
|
+
<span className="block text-xs text-muted-foreground">
|
|
86
|
+
~{rate.estimatedDays} day{rate.estimatedDays === 1 ? '' : 's'}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</span>
|
|
90
|
+
<span className="shrink-0 font-medium text-foreground">
|
|
91
|
+
{formatPrice(rate.amount, rate.currency)}
|
|
92
|
+
</span>
|
|
93
|
+
</button>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</Card>
|
|
97
|
+
))}
|
|
98
|
+
|
|
99
|
+
{error && (
|
|
100
|
+
<Alert role="alert" variant="destructive">
|
|
101
|
+
<AlertDescription>{error.message ?? error.code}</AlertDescription>
|
|
102
|
+
</Alert>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
<Button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={onContinue}
|
|
108
|
+
disabled={pending || !allSelected}
|
|
109
|
+
data-fc-track="checkout-to-payment"
|
|
110
|
+
>
|
|
111
|
+
{pending ? 'Saving…' : 'Continue to payment'}
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
import type { ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
import { cn } from './utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The shadcn/ui alert, with the status variants this variant declares.
|
|
8
|
+
*
|
|
9
|
+
* shadcn/ui ships `default` and `destructive`; the storefront also speaks
|
|
10
|
+
* success, warning and info, so those three are variants here, each filled
|
|
11
|
+
* with its own `--success` / `--warning` / `--info` token and the
|
|
12
|
+
* `-foreground` that stays legible on it (see the mapping table in
|
|
13
|
+
* `src/app/globals.css`). An alert is `role="alert"` unless the caller says
|
|
14
|
+
* otherwise — a polite outcome passes `role="status"`.
|
|
15
|
+
*/
|
|
16
|
+
const alertVariants = cva(
|
|
17
|
+
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
|
18
|
+
{
|
|
19
|
+
variants: {
|
|
20
|
+
variant: {
|
|
21
|
+
default: 'bg-card text-card-foreground',
|
|
22
|
+
destructive:
|
|
23
|
+
'bg-card text-destructive [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
|
24
|
+
success:
|
|
25
|
+
'border-success bg-success text-success-foreground *:data-[slot=alert-description]:text-success-foreground/90',
|
|
26
|
+
warning:
|
|
27
|
+
'border-warning bg-warning text-warning-foreground *:data-[slot=alert-description]:text-warning-foreground/90',
|
|
28
|
+
info: 'border-info bg-info text-info-foreground *:data-[slot=alert-description]:text-info-foreground/90',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {
|
|
32
|
+
variant: 'default',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
function Alert({
|
|
38
|
+
className,
|
|
39
|
+
variant,
|
|
40
|
+
...props
|
|
41
|
+
}: ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
data-slot="alert"
|
|
45
|
+
role="alert"
|
|
46
|
+
className={cn(alertVariants({ variant }), className)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function AlertTitle({ className, ...props }: ComponentProps<'div'>) {
|
|
53
|
+
return (
|
|
54
|
+
<div
|
|
55
|
+
data-slot="alert-title"
|
|
56
|
+
className={cn('col-start-2 min-h-4 font-medium tracking-tight', className)}
|
|
57
|
+
{...props}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function AlertDescription({ className, ...props }: ComponentProps<'div'>) {
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
data-slot="alert-description"
|
|
66
|
+
className={cn(
|
|
67
|
+
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
|
68
|
+
className,
|
|
69
|
+
)}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { Alert, AlertDescription, AlertTitle };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from './utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The shadcn/ui badge — the cart count, a subscription line's label, an
|
|
9
|
+
* applied coupon. As with the button, the variant shadcn/ui paints with a raw
|
|
10
|
+
* `text-white` is not vendored: every colour here is a token.
|
|
11
|
+
*/
|
|
12
|
+
const badgeVariants = cva(
|
|
13
|
+
'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&>svg]:pointer-events-none [&>svg]:size-3',
|
|
14
|
+
{
|
|
15
|
+
variants: {
|
|
16
|
+
variant: {
|
|
17
|
+
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
|
18
|
+
secondary:
|
|
19
|
+
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
|
20
|
+
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
variant: 'default',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
function Badge({
|
|
30
|
+
className,
|
|
31
|
+
variant,
|
|
32
|
+
asChild = false,
|
|
33
|
+
...props
|
|
34
|
+
}: ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
|
35
|
+
const Comp = asChild ? Slot : 'span';
|
|
36
|
+
|
|
37
|
+
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export { Badge, badgeVariants };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Slot } from '@radix-ui/react-slot';
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
3
|
+
import type { ComponentProps } from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from './utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The shadcn/ui button. `buttonVariants` is exported on its own so a link
|
|
9
|
+
* styled as a button keeps its own element — `<LocaleLink className={
|
|
10
|
+
* buttonVariants()}>` stays one crawlable `<a>` — and `asChild` hands the
|
|
11
|
+
* classes to whatever single child it wraps instead.
|
|
12
|
+
*
|
|
13
|
+
* Every colour is a token of the `:root` block in `src/app/globals.css`; the
|
|
14
|
+
* template has no destructive action, so the variant shadcn/ui paints with a
|
|
15
|
+
* raw `text-white` is not vendored.
|
|
16
|
+
*/
|
|
17
|
+
const buttonVariants = cva(
|
|
18
|
+
"inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
19
|
+
{
|
|
20
|
+
variants: {
|
|
21
|
+
variant: {
|
|
22
|
+
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
|
23
|
+
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
|
24
|
+
outline:
|
|
25
|
+
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
|
26
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
27
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
28
|
+
},
|
|
29
|
+
size: {
|
|
30
|
+
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
31
|
+
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
|
32
|
+
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
|
33
|
+
icon: 'size-9',
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
defaultVariants: {
|
|
37
|
+
variant: 'default',
|
|
38
|
+
size: 'default',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function Button({
|
|
44
|
+
className,
|
|
45
|
+
variant,
|
|
46
|
+
size,
|
|
47
|
+
asChild = false,
|
|
48
|
+
...props
|
|
49
|
+
}: ComponentProps<'button'> &
|
|
50
|
+
VariantProps<typeof buttonVariants> & {
|
|
51
|
+
asChild?: boolean;
|
|
52
|
+
}) {
|
|
53
|
+
const Comp = asChild ? Slot : 'button';
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Comp
|
|
57
|
+
data-slot="button"
|
|
58
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
59
|
+
{...props}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from './utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shadcn/ui card: a raised `bg-card` surface with its border and radius.
|
|
7
|
+
* The template's cards carry their own headings — a product tile's `<h3>`, a
|
|
8
|
+
* cart panel's `<h2>` — so the heading level stays the page's decision, and
|
|
9
|
+
* `CardContent` pads whatever sits inside.
|
|
10
|
+
*/
|
|
11
|
+
function Card({ className, ...props }: ComponentProps<'div'>) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
data-slot="card"
|
|
15
|
+
className={cn(
|
|
16
|
+
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function CardContent({ className, ...props }: ComponentProps<'div'>) {
|
|
25
|
+
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export { Card, CardContent };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from './utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shadcn/ui text input. An invalid field is marked by `aria-invalid` —
|
|
7
|
+
* the attribute a screen reader announces is the one that paints the
|
|
8
|
+
* destructive border, so the two can never disagree.
|
|
9
|
+
*/
|
|
10
|
+
function Input({ className, type, ...props }: ComponentProps<'input'>) {
|
|
11
|
+
return (
|
|
12
|
+
<input
|
|
13
|
+
type={type}
|
|
14
|
+
data-slot="input"
|
|
15
|
+
className={cn(
|
|
16
|
+
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
|
17
|
+
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
18
|
+
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
|
19
|
+
className,
|
|
20
|
+
)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { Input };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as LabelPrimitive from '@radix-ui/react-label';
|
|
4
|
+
import type { ComponentProps } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from './utils';
|
|
7
|
+
|
|
8
|
+
/** The shadcn/ui form label, on Radix's label primitive. */
|
|
9
|
+
function Label({ className, ...props }: ComponentProps<typeof LabelPrimitive.Root>) {
|
|
10
|
+
return (
|
|
11
|
+
<LabelPrimitive.Root
|
|
12
|
+
data-slot="label"
|
|
13
|
+
className={cn(
|
|
14
|
+
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
{...props}
|
|
18
|
+
/>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { Label };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from './utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A native `<select>` dressed as a shadcn/ui field. The checkout's country
|
|
7
|
+
* select stays the browser's own control — it takes part in the form's
|
|
8
|
+
* `required` validation and in the platform's address autofill, which a
|
|
9
|
+
* custom listbox would have to rebuild — and only borrows the input's
|
|
10
|
+
* border, focus ring and `aria-invalid` treatment.
|
|
11
|
+
*/
|
|
12
|
+
function NativeSelect({ className, ...props }: ComponentProps<'select'>) {
|
|
13
|
+
return (
|
|
14
|
+
<select
|
|
15
|
+
data-slot="native-select"
|
|
16
|
+
className={cn(
|
|
17
|
+
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
|
18
|
+
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
|
|
19
|
+
'aria-invalid:border-destructive aria-invalid:ring-destructive/20',
|
|
20
|
+
className,
|
|
21
|
+
)}
|
|
22
|
+
{...props}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export { NativeSelect };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComponentProps } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cn } from './utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The shadcn/ui loading placeholder. It pulses on `bg-muted`, the quiet
|
|
7
|
+
* surface, rather than on `bg-accent`: this variant's accent carries the
|
|
8
|
+
* daisyUI baseline's strong highlight, which would turn every loading block
|
|
9
|
+
* near-black.
|
|
10
|
+
*/
|
|
11
|
+
function Skeleton({ className, ...props }: ComponentProps<'div'>) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
data-slot="skeleton"
|
|
15
|
+
className={cn('animate-pulse rounded-md bg-muted', className)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { Skeleton };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* shadcn/ui's class helper: joins conditional classes (`clsx`) and lets a
|
|
6
|
+
* later utility win over an earlier one of the same group (`tailwind-merge`),
|
|
7
|
+
* so a component's own classes can be overridden from its `className`.
|
|
8
|
+
*
|
|
9
|
+
* It lives beside the vendored components rather than under `src/lib/`: the
|
|
10
|
+
* library holds the storefront's logic, which every row of the template set
|
|
11
|
+
* shares byte for byte, and this helper belongs to the shadcn/ui components
|
|
12
|
+
* alone. `components.json` points the shadcn/ui CLI's `utils` alias here.
|
|
13
|
+
*/
|
|
14
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
15
|
+
return twMerge(clsx(inputs));
|
|
16
|
+
}
|