// ── composite ───────────────────────────────── // // Convenience component that renders the full Design-5 layout (card-first + // inline GPay button + status strip + trust footer + gpay_ready state). // Hosts that want full control can use `usePaymentFields` directly and render // their own JSX. // // Renders fine in either a normal React tree (WC Blocks) or inside a Shadow // DOM (web-component layer renders this via createRoot(shadowRoot)). // // For lifecycle-callback notifications (onTokenized / onError / onThreeDsRequired), // wire them on the `config` you pass in — those fire from the machine directly, // so they work regardless of which layout you render. import { useEffect, useId, useMemo, useRef, useTransition } from 'react'; import type { FieldKey, MountTargets, PaymentFieldsConfig, PaymentFieldsError, PaymentFieldsMachine, PaymentFieldsState, } from '../core/index'; import { PAYMENT_FIELDS_CSS } from './styles'; import { usePaymentFields, type UsePaymentFieldsReturn } from './use-payment-fields'; export interface PaymentFieldsProps { /** Config to construct a machine. Exactly one of `config` or `machine` must be set. */ readonly config?: PaymentFieldsConfig; /** Existing machine to render against (used by the web-component layer). Exactly one of `config` or `machine` must be set. */ readonly machine?: PaymentFieldsMachine; /** URL for the trust-footer lock icon (e.g., `.../site/img/lock.svg`). */ readonly lockIconSrc: string; /** URL for the trust-footer logos PNG (180×40 in the production asset). */ readonly trustLogosSrc: string; /** Trust-footer wording. Defaults to "Payment secured by" — preserved verbatim from the WP plugin. */ readonly trustText?: string; /** * Full-control render-prop. When provided, the default layout is skipped and * you render everything yourself using the returned API. Useful when the * host owns the layout (e.g., WC Blocks Content component). */ readonly children?: (api: UsePaymentFieldsReturn) => React.ReactNode; } export function PaymentFields(props: PaymentFieldsProps): React.ReactElement { const input = props.machine ?? props.config; if (!input) { throw new Error('PaymentFields requires either a `config` or a `machine` prop.'); } const api = usePaymentFields(input); // Stable per-instance ID prefix for mount-point selectors. Inside Shadow DOM, // the selectors only need to be unique within the shadowRoot — but we prefix // anyway so the same component can render multiple times on one page. const reactId = useId(); const idPrefix = useMemo(() => `wcp-pf-${reactId.replace(/[^a-zA-Z0-9_-]/g, '')}`, [reactId]); const targets = useMemo( () => ({ cardNumber: `#${idPrefix}-card-number`, cardDate: `#${idPrefix}-card-date`, cardCvv: `#${idPrefix}-card-cvv`, cardPostalCode: `#${idPrefix}-card-postal-code`, cardName: `#${idPrefix}-card-name`, cardEmail: `#${idPrefix}-card-email`, paymentRequestButton: `#${idPrefix}-payment-request-button`, }), [idPrefix], ); // Mount once after the mount-point divs are in the DOM (first render commit). const mountStarted = useRef(false); useEffect(() => { if (mountStarted.current) { return; } mountStarted.current = true; api.mount(targets).catch((err: unknown) => { console.warn('[wcp-payment-fields] mount failed', err); }); }, [api, targets]); if (props.children) { return <>{props.children(api)}; } return ( ); } // ── Default Design-5 layout ──────────────────────────────────── interface DefaultLayoutProps { readonly api: UsePaymentFieldsReturn; readonly idPrefix: string; readonly lockIconSrc: string; readonly trustLogosSrc: string; readonly trustText: string; readonly googlePayEnabled: boolean; readonly cardholderVisible: boolean; } function DefaultLayout(props: DefaultLayoutProps) { const { api, idPrefix, googlePayEnabled, cardholderVisible } = props; const isGpayReady = api.state === 'gpay_ready'; // When GPay is ready, the card fields collapse out entirely (max-height/opacity // transition). For in-progress states (tokenizing/submitting/3DS), we keep them // visible but dimmed so the user can still see what they entered. const isFormCollapsed = isGpayReady; const isFormDimmed = !isGpayReady && isInProgress(api.state); const [isPending, startTransition] = useTransition(); const onUseCard = () => { startTransition(() => api.resetToIdle()); }; const machineConfig = api.machine.config; const formattedAmount = formatAmount( machineConfig.cartTotal, machineConfig.currency, machineConfig.locale, ); return (
{!isGpayReady && ( )} {isGpayReady && api.result && ( )}
{cardholderVisible && (
)}
{googlePayEnabled && !isGpayReady && (
Or pay instantly
)}
); } // ── Sub-components ───────────────────────────────────────────── function StatusStrip({ state, error }: { state: PaymentFieldsState; error?: PaymentFieldsError }) { const message = stateMessage(state, error); if (!message) { return null; } return (
{message.icon} {message.text}
); } function GpayReadyCard(props: { brand: string; last4: string; amount: string; onUseCard: () => void; isPending: boolean; }) { return (
Google Pay is ready.
Your payment is queued and runs when you submit.
{props.amount}
via Google Pay {props.brand} •••• {props.last4}

Click Place Order below to confirm.

); } function formatAmount(cents: number, currency: string, locale: string): string { try { return new Intl.NumberFormat(locale, { style: 'currency', currency, }).format(cents / 100); } catch { return `${(cents / 100).toFixed(2)} ${currency}`; } } function TrustFooter(props: { lockIconSrc: string; trustLogosSrc: string; trustText: string }) { return (
{props.trustText} Secured by Clover & WeeConnectPay
); } // ── helpers ───────────────────────────────────────────────────── function cardholderVisibility(props: PaymentFieldsProps): boolean { const features = props.config?.features ?? props.machine?.config?.features; const setting = features?.cardholderFields ?? 'auto'; if (setting === 'always') return true; if (setting === 'hidden') return false; return features?.threeDSecure === true; } function featureFlag(props: PaymentFieldsProps, flag: 'googlePay' | 'threeDSecure'): boolean { const features = props.config?.features ?? props.machine?.config?.features; return features?.[flag] === true; } /** * Renders a single Clover-iframe mount point with deferred error text and * positive (valid) confirmation. * * Wrapped in a `wcp-pf__field-slot` div so it can sit in a row/grid cell as a * single child while the error text flows underneath the field without * disrupting sibling field alignment (parent grids use `align-items: start`). * * Error visual reads `displayedError`, NOT raw `error` — so it stays neutral * until the user has blurred once (deferred-error rule, see ValidationMachine * comment block). The `--valid` green check, on the other hand, reacts * immediately to `touched + no error` — positive feedback is never deferred. */ function FieldSlot(props: { readonly api: UsePaymentFieldsReturn; readonly fieldKey: FieldKey; readonly mountId: string; }): React.ReactElement { const field = props.api.validation.fields[props.fieldKey]; const displayedError = field?.displayedError; // Clover doesn't distinguish empty from valid for cardName/cardEmail (see // ValidationMachine comment), so we require at least one change before // calling them valid — otherwise focus+blur with an empty field would draw // a green check on nothing. const requiresChangeForValid = props.fieldKey === 'cardName' || props.fieldKey === 'cardEmail'; const valid = !!field?.touched && !field?.error && (!requiresChangeForValid || !!field?.hadChangeEver); const fieldClass = displayedError ? 'wcp-pf__field wcp-pf__field--error' : valid ? 'wcp-pf__field wcp-pf__field--valid' : 'wcp-pf__field'; const showCheck = valid && props.fieldKey !== 'cardNumber'; return (
{showCheck && ( )}
{displayedError && (
{displayedError}
)}
); } function isInProgress(state: PaymentFieldsState): boolean { return ( state === 'gpay_opening' || state === 'tokenizing' || state === 'submitting' || state === 'threeds_method' || state === 'threeds_challenge' || state === 'finalizing' ); } interface StripMessage { readonly text: string; readonly tone: 'info' | 'warning' | 'success' | 'error'; readonly icon: React.ReactNode; } function stateMessage(state: PaymentFieldsState, error?: PaymentFieldsError): StripMessage | null { switch (state) { case 'idle': case 'gpay_cancelled': return null; case 'gpay_opening': return { text: 'Opening Google Pay…', tone: 'info', icon: }; case 'gpay_ready': return { text: 'Google Pay ready — place your order to confirm.', tone: 'success', icon: , }; case 'tokenizing': return { text: 'Securing card details…', tone: 'info', icon: }; case 'submitting': return { text: 'Sending payment…', tone: 'info', icon: }; case 'threeds_method': return { text: 'Preparing bank verification…', tone: 'info', icon: }; case 'threeds_challenge': return { text: 'Complete verification with your bank →', tone: 'warning', icon: }; case 'finalizing': return { text: 'Confirming payment…', tone: 'info', icon: }; case 'done': return { text: 'Payment confirmed.', tone: 'success', icon: }; case 'error': return { text: error?.message ?? 'Something went wrong. Please try again.', tone: 'error', icon: , }; } } // ── icons ─────────────────────────────────────────────────────── function Spinner() { return