// ── 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 (
);
}
// ── 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 ;
}
function Checkmark() {
return (
);
}
function Shield() {
return (
);
}
function ErrorIcon() {
return (
);
}