# @weeconnectpay/payment-fields

Reusable payment-fields SDK for WeeConnectPay integrations. Wraps Clover's iframe elements (card number, expiry, CVV, postal, cardholder name, cardholder email, Payment Request Button) and the 3-D Secure flow into a single composable surface. Ships in three flavors:

- **`@weeconnectpay/payment-fields`** — vanilla TS core. State machine, Clover SDK loader, tokenization, 3DS lifecycle, browser-info collection. Framework-free.
- **`@weeconnectpay/payment-fields/react`** — React 19 hook (`usePaymentFields`) and `<PaymentFields>` composite component built on `useSyncExternalStore` over the core machine.
- **`@weeconnectpay/payment-fields/web-component`** — `<wcp-payment-fields>` custom element with Shadow DOM. Form-associated. Custom events for the host to listen to. The default integration surface for WordPress / vanilla JS hosts.

Mirrors the architecture of `@weeconnectpay/device-flow` (sibling package).

## Status

**v0.1.0 — scaffold only.** Source is stubs; nothing functional yet. Implementation lands in subsequent tasks (see `~/.claude/plans/so-in-the-wordpress-sharded-pearl.md`).

## Why this exists

The WordPress plugin currently maintains two near-identical payment field implementations:

- `payment_fields/ts/payment-fields.ts` (~1,623 lines, vanilla TS, WC Classic checkout)
- `payment-fields-blocks/resources/ts/frontend/index.tsx` (~523 lines, React, WC Blocks checkout)

Plus a third lurking in the API repo for the dashboard checkout, and a fourth in the PrestaShop plugin. Each diverges from the others over time, each is its own bug surface. This SDK consolidates the Clover-iframe + 3DS + Google Pay logic into one place that all hosts can adopt.

It also fixes a class of bugs unique to WordPress: WP themes consistently override the plugin's CSS. The web-component layer puts the entire UI inside a Shadow DOM, eliminating selector-specificity wars with host themes once and for all.

## Architectural constraints baked into the API

### 1. Clover styles are JSON, not CSS

Clover's `elements.create(type, styles)` takes an object with CSS-selector keys (e.g. `'card-number input'`) mapped to objects of CSS properties. Not a stylesheet. Not className. Today this is typed `object`. We export a real `CloverElementStyles` type covering the selector and property domains, plus a higher-level `theme: CloverFieldTheme` config that emits the JSON internally so consumers rarely write raw selector strings.

**Mechanics that bite if you forget them** (full reverse-engineered notes in the project memory `project_clover_iframe_elements_internals`):
- The styles JSON is **URL-encoded onto the iframe's `src`** — there's no postMessage style channel. Long objects = long URLs. Practical cap: a few KB.
- Selectors Clover honors: only `input`, `card-X input` (per element), and placeholder pseudos. **No `body`, `html`, `*`, classes, or IDs.**
- Properties Clover honors: typography (`color`, `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `textAlign`, `textIndent`), box (`padding`, `margin`, `height`, `width`), border (`border`, `borderRadius`), background (`background`, `backgroundColor`). **No `overflow`, no `box-sizing`, no `min-height`, no `max-width`.**
- Inside each iframe, Clover ships a 5-rule stylesheet (`assets/style.css`) — `html { height: 100% }`, `body { margin: 0 }`, `label { display: none }`, `input { height: 1.2em; box-sizing: border-box; ... }`, plus card-brand badge positioning. You can override the input via the JSON; you can't touch `html`/`body`/`label`.
- **Clover auto-resizes the iframe element to its rendered content** via a postMessage handler (`updateFrameStyles` in sdk.js). The iframe document measures itself and Clover applies the measured `height`/`width` to the iframe element inline. **Don't override iframe height with `!important` outside** — you'll force a viewport smaller than the rendered content and get per-field scrollbars. Match the input height in the JSON to the wrapper height instead; let Clover's auto-sizer fit them together.

### 2. No field labels — Clover renders placeholders

Each Clover iframe (`CARD_NUMBER`, `CARD_DATE`, `CARD_CVV`, `CARD_POSTAL_CODE`, `CARD_NAME`, `CARD_EMAIL_ADDRESS`) renders its own locale-aware placeholder text ("Card Number", "MM/YY", "CVV", "ZIP" → "POSTAL CODE" in fr-CA, "Cardholder Name", "Email Address"). The SDK's layout templates do not render labels above these iframes — that would duplicate what Clover already shows.

### 3. Google Pay is a Clover iframe, not a custom button

The GPay button is `CloverElementType.PAYMENT_REQUEST_BUTTON` — a Clover SDK iframe loading Google Pay (and Apple Pay where supported) via the browser PaymentRequest API. Clover owns the button's pixels. We control only the mount div's size and surrounding spacing. The flow fires three events: `paymentMethodStart`, `paymentMethod`, `paymentMethodEnd` (with a ~1s cancellation debounce on the third).

**Full event surface across all Clover elements** (from `registerElementEventListener` in sdk.js — passing any other event name throws):

| Event | Payload (on `e.data`) | Element types that fire it |
|---|---|---|
| `change`, `blur`, `focus` | `{ elementType, realTimeFormState }` where `realTimeFormState` is keyed by element type with `{ touched, error? }` per field | All card fields (`CARD_*`) |
| `paymentMethodStart` | `{ elementType, responseData }` | Wallet buttons (`PAYMENT_REQUEST_BUTTON`, `*_APPLE_PAY`, `*_SAMSUNG_PAY`, `*_PAZE`) |
| `paymentMethod` | `{ elementType, responseData }` — wallet token captured | Wallet buttons |
| `paymentMethodEnd` | `{ elementType, responseData }` — if `paymentMethod` didn't fire within ~1s, user cancelled | Wallet buttons |
| `paymentAuthorize` | `{ applePayToken, emailAddress, customerName, shippingContact, billingContact, elementType }` | Apple Pay |
| `appleButtonClick`, `shippingAddressChange`, `shippingMethodChange` | Apple Pay UX hooks | Apple Pay |
| `startPhoneAuthentication`, `startOTPAuthentication` | ACH-specific | ACH elements |

Subscribe via `element.addEventListener(name, callback)` — it's the DOM `EventTarget` interface, not an emitter. Each (element, event) accepts exactly one callback; double-subscribing throws "X event is already registered on Y".

### 4. Google Pay flow is "prepare-and-wait", never auto-submit

When the customer confirms in the Google Pay sheet (Clover fires `paymentMethod`), the SDK captures the token internally and enters a `gpay_ready` state visually equivalent to "card form filled out and valid". The SDK **does not submit the order**. The customer's next action is the host's Place Order button (WC's `process_payment`, dashboard's submit, PrestaShop's submit). When the host calls `tokenize()`, the SDK returns the already-captured GPay token immediately.

### 5. Trust footer uses the production assets verbatim

The "Payment secured by [Clover & WeeConnectPay logos]" footer mounts the existing `site/img/lock.svg` and `site/img/secured-by-logos.png` (180×40, non-square). Layout mirrors `weeconnect-public.css:140-167`. Wording is preserved verbatim per product requirement.

## Public API (planned — currently stubbed)

### Core

```ts
import { PaymentFieldsMachine, type PaymentFieldsConfig } from '@weeconnectpay/payment-fields';

const machine = new PaymentFieldsMachine({
  pakmsKey, merchantId, locale, cartTotal,
  features: { googlePay, threeDSecure, cardholderFields, savedCredentials },
  endpoints: { threeDsFinalize, headers },
  onTokenized, onThreeDsRequired, onError,
});
machine.subscribe((snapshot) => {});
machine.mount({ cardNumber: '#id', cardDate: '#id', /* ... */ });
await machine.tokenize();
machine.destroy();
```

### React

```ts
import { usePaymentFields, PaymentFields } from '@weeconnectpay/payment-fields/react';

const { state, errors, canSubmit, tokenize } = usePaymentFields(config);
// or
<PaymentFields layout="card-only-minimal" config={config} />
```

### Web component

```html
<wcp-payment-fields
  layout="card-only-minimal"
  pakms-key="..." merchant-id="..." cart-total="4299"
  features='{"googlePay":true,"threeDSecure":true}'
  endpoints='{"threeDsFinalize":"/wp-json/weeconnectpay/v1/woocommerce/3ds/finalize"}'>
</wcp-payment-fields>

<script>
  const el = document.querySelector('wcp-payment-fields');
  el.addEventListener('wcp-tokenized', e => form.submit(e.detail));
  el.addEventListener('wcp-error', e => showNotice(e.detail.message));
  el.addEventListener('wcp-gpay-start', () => {});
  el.addEventListener('wcp-gpay-ready', () => {}); // token captured, awaiting Place Order
  el.addEventListener('wcp-gpay-cancel', () => {});
  el.addEventListener('wcp-3ds-start', () => {});
  el.addEventListener('wcp-3ds-done', () => {});
  await el.tokenize();
</script>
```

The custom element uses `static formAssociated = true` and `ElementInternals.setFormValue()` to participate in the enclosing `<form>` so WC's classic submit semantics work without changes to the PHP gateway.

## Validation snapshot & deferred errors

The SDK exposes a normalized per-field state via `snapshot.validation.fields[fieldKey]`. This is computed by `ValidationMachine` on top of Clover's raw `change` / `blur` events.

### Clover's raw event payload

Each `change` / `blur` event fires on the `CloverElement` itself (DOM `EventTarget`). The payload spreads ALL fields' state directly onto the Event object — not just the field that fired (verified via discovery 2026-05-18):

```js
element.addEventListener('change', (e) => {
  e.CARD_NUMBER         // { touched, error?, info }
  e.CARD_DATE           // { touched, error?, info }
  e.CARD_CVV            // { touched, error?, info }
  e.CARD_POSTAL_CODE    // { touched, error?, info }
  e.CARD_NAME           // { touched, error?, info }
  e.CARD_EMAIL_ADDRESS  // { touched, error?, info }
});
```

The `info` field is always `""` in observed behavior — reserved by Clover but unused. Quirks per field:

| Field | Empty | Invalid | Valid |
|---|---|---|---|
| `CARD_NUMBER` | `error: "Card number is required"` | `error: "Card number is invalid"` | no `error` key |
| `CARD_DATE` | `error: "Card expiry is required"` | `error: "Card expiry is invalid"` | no `error` key |
| `CARD_CVV` | `error: "Card CVV is required"` | `error: "Card CVV is invalid"` | no `error` key |
| `CARD_POSTAL_CODE` | `error: "Card postal code is required"` | (accepts any text) | no `error` key |
| `CARD_NAME` | no `error` key | `error: "Cardholder name is invalid"` (e.g. 1 char) | no `error` key |
| `CARD_EMAIL_ADDRESS` | `error: ""` | `error: "Email address is invalid"` | `error: ""` |

Note that `CARD_NAME` and `CARD_EMAIL_ADDRESS` **do not differentiate empty from valid** — Clover doesn't enforce required-ness on these. The SDK layers its own "has the user typed?" tracking via `hadChangeEver` (see below) to compensate.

Also note that Clover's iframe CSS selectors use the **lower-kebab form of the element-type name** — `card-email-address`, not `card-email`. Shortened selectors are silently ignored. The legacy WP plugin had this bug for years; the v0.1 SDK ships with the correct full-name selectors.

### `FieldValidationState`

```ts
interface FieldValidationState {
  readonly touched: boolean;            // Clover-reported: user has interacted
  readonly error?: string;              // Clover-reported, normalized: undefined when Clover sends "" or omits the key
  readonly hasBlurred: boolean;         // SDK-derived: user has blurred this field at least once
  readonly hadChangeEver: boolean;      // SDK-derived: at least one change event observed for this field
  readonly displayedError?: string;     // SDK-derived: what the UI should render
}
```

**`error` vs `displayedError`** is the marquee distinction. Read `displayedError` when driving UI. Read `error` only when you genuinely need the raw signal (e.g., gating `canSubmit` server-side).

### Deferred-error rule

Industry-standard UX (Stripe Elements, Square, Braintree): errors aren't shown while the user is mid-typing — they're deferred until the field is blurred at least once. Positive feedback (the "this is valid" green check) is *not* deferred — it shows immediately. Asymmetric on purpose.

Rules driving `displayedError`:

| Event | Condition | `displayedError` |
|---|---|---|
| `change` | `hasBlurred === false` | stays `undefined` regardless of `error` |
| `change` | `hasBlurred === true` | mirrors `error` (live update as user fixes) |
| `blur` | always | mirrors `error` (sets `hasBlurred = true`) |

The default `<FieldSlot>` in the React composite uses `displayedError` to drive the `--error` border, and raw `touched && !error` to drive the `--valid` green check. Mirror this asymmetry in custom UIs.

### `canSubmit`

Computed at the snapshot level: true iff every required field has `touched === true && !error`. This uses the **raw** error, not the displayed one — submission should be blocked even on fields the user hasn't blurred yet but that Clover already knows are invalid.

### Cardholder name + email validation

`cardName` and `cardEmail` are passthrough fields for 3DS. Clover does not enforce non-empty on them — empty blur looks identical to valid input in the raw event payload. The SDK layers its own "did the user actually type?" check via `hadChangeEver`:

- **Green check (`--valid`)** is suppressed on `cardName` / `cardEmail` until `hadChangeEver === true`, so a focus+blur with an empty field never paints as valid.
- **`displayedError`** is set to `"Required"` on blur if `hadChangeEver === false` and the field is required.
- **`canSubmit`** requires `hadChangeEver === true` for any required `cardName` / `cardEmail`, in addition to the standard `touched && !error`.

Edge case we deliberately don't catch: type-then-delete-to-empty. We can't read the iframe value (cross-origin), so `hadChangeEver` stays `true` even when the field is empty again. Server-side validation at tokenize/finalize time remains the authoritative gate for those cases.

### Driving custom UI without React

```ts
const machine = new PaymentFieldsMachine(config);
machine.subscribe((snapshot) => {
  for (const [fieldKey, field] of Object.entries(snapshot.validation.fields)) {
    const wrapper = document.querySelector(`#field-${fieldKey}`);
    wrapper?.classList.toggle('error', !!field.displayedError);
    wrapper?.classList.toggle('valid', field.touched && !field.error);
    const msg = wrapper?.nextElementSibling;
    if (msg) msg.textContent = field.displayedError ?? '';
  }
  submitBtn.disabled = !snapshot.validation.canSubmit;
});
await machine.mount({ /* selectors */ });
```

## Lifecycle states

| State | Trigger | Customer-visible |
|---|---|---|
| `idle` | Default | (no strip) |
| `gpay_opening` | Clover `paymentMethodStart` | "Opening Google Pay…" + card form dims |
| `gpay_ready` | Clover `paymentMethod` (token captured) | "Google Pay ready — place your order to confirm" |
| `gpay_cancelled` | `paymentMethodEnd` without `paymentMethod` within ~1s | (transient, dissolves to idle) |
| `tokenizing` | Host called `tokenize()` (manual card path) | "Securing card details…" |
| `submitting` | POSTing the charge | "Sending payment…" |
| `threeds_method` | API → `THREEDS_METHOD_FLOW_REQUIRED` | "Preparing bank verification…" |
| `threeds_challenge` | API → `CHALLENGE_FLOW_REQUIRED` | "Complete verification with your bank →" + dims |
| `finalizing` | 3DS callback fired; POST `/3ds/finalize` | "Confirming payment…" |
| `done` | Terminal success | "✓ Payment confirmed" |
| `error` | Tokenization / charge / 3DS failure | inline error |

See `design-previews/06-states.html` in the WP plugin repo for visual reference.

## Building

```bash
npm install
npm run build       # tsup → dist/{core,react,web-component}/index.{js,cjs,d.ts}
npm run dev         # watch mode
npm run typecheck   # tsc --noEmit
```

Build emits ESM + CJS + type declarations for each of the three subpath exports.

## Consuming locally (during WP plugin development)

The WordPress plugin consumes this package via a file: dependency in `payment-fields-blocks/package.json` and a vite alias in the classic-checkout bundle (`vite.config.ts`). See those host adapters for the exact wiring. The package can also be published to npm when other integrators need it externally.

## License

MIT
