// ── Per-field validation state ────────────────────────────────── // // Listens to Clover's `change` and `blur` events on each mounted element, // tracks per-field state, and emits `ValidationSnapshot` to subscribers. // // ── Two-layer error model ───────────────────────────────────── // // raw error ← what Clover reports (every change/blur) // displayed ← what the UI shows (deferred until first blur) // // Why two layers: Clover fires `change` events with `error: "incomplete"` // or similar on every keystroke. Showing a red border + shake while the // user is typing "4242" is bad UX — they haven't finished entering the // number yet. Mirrors Stripe Elements, Square Web Payments, Braintree. // // Rules driving `displayedError`: // 1. `change` before first blur → leave `displayedError` undefined // 2. `blur` event → copy raw error to `displayedError` // 3. `change` after first blur → copy raw error to `displayedError` // (live error-clearing as user fixes) // // Positive feedback (green check) reads raw `touched && !error` directly — // it never gets deferred. Asymmetric on purpose. // // ── Cardholder name / email caveat ─────────────────────────── // // Clover does NOT distinguish empty from valid for `CARD_NAME` and // `CARD_EMAIL_ADDRESS` (discovery 2026-05-18): // - cardName empty: no `error` key at all // - cardName valid: no `error` key at all // - cardEmail empty: `error: ""` // - cardEmail valid format: `error: ""` // So we layer our own "empty?" detection on top, using `hadChangeEver` as a // proxy. If the user blurs without ever firing a `change` event, the field // is empty and — if required — we emit `displayedError: "Required"` and // suppress the green check. We can't catch type-then-delete-to-empty (no // access to the iframe value), so server-side validation remains the // authoritative gate for those edge cases. // // ── Clover event shape ──────────────────────────────────────── // // Each event Clover fires carries the full state map for every mounted field, // not just the field that triggered it (verified via discovery 2026-05-18): // // event.CARD_NUMBER = { touched, error?, info } // event.CARD_DATE = { touched, error?, info } // event.CARD_CVV = { touched, error?, info } // event.CARD_POSTAL_CODE = { touched, error?, info } // event.CARD_NAME = { touched, error?, info } // event.CARD_EMAIL_ADDRESS = { touched, error?, info } // // We reconcile the full snapshot on every event, but only credit the field // that actually fired the event (the listener's owning fieldKey) with // `hadChangeEver` / `hasBlurred` flips. import type { CloverElement, CloverElementType, FieldKey, FieldValidationState, Unsubscribe, ValidationSnapshot, } from './types'; /** Field keys that participate in validation. Payment Request Button does not. */ const VALIDATING_FIELDS: readonly FieldKey[] = [ 'cardNumber', 'cardDate', 'cardCvv', 'cardPostalCode', 'cardName', 'cardEmail', ]; /** * Fields Clover doesn't enforce non-empty for — we emit "Required" ourselves * on blur if these are required and the user never typed anything. */ const REQUIRES_NON_EMPTY_OVERRIDE: ReadonlySet = new Set(['cardName', 'cardEmail']); const CLOVER_KEY_TO_FIELD_KEY: Record = { CARD_NUMBER: 'cardNumber', CARD_DATE: 'cardDate', CARD_CVV: 'cardCvv', CARD_POSTAL_CODE: 'cardPostalCode', CARD_NAME: 'cardName', CARD_EMAIL_ADDRESS: 'cardEmail', }; interface CloverFieldEventState { touched: boolean; error?: string; } type CloverEventType = 'change' | 'blur'; interface InternalFieldState { touched: boolean; error?: string; hasBlurred: boolean; hadChangeEver: boolean; displayedError?: string; } function emptyFieldState(): InternalFieldState { return { touched: false, error: undefined, hasBlurred: false, hadChangeEver: false, displayedError: undefined, }; } function toPublic(s: InternalFieldState): FieldValidationState { return { touched: s.touched, error: s.error, hasBlurred: s.hasBlurred, hadChangeEver: s.hadChangeEver, displayedError: s.displayedError, }; } function isMeaningfulError(err: string | undefined): boolean { // Clover uses `""` (empty string) for cardEmail to mean "no error". Treat it // the same as `undefined` so downstream callers only see real error strings. return err !== undefined && err !== ''; } export class ValidationMachine { private readonly state = new Map(); private readonly required = new Set(); private readonly listeners = new Set<(snapshot: ValidationSnapshot) => void>(); constructor(requiredFields: readonly FieldKey[]) { for (const f of requiredFields) { if (VALIDATING_FIELDS.includes(f)) { this.required.add(f); } } } /** Wire up the validation machine to a set of Clover elements. */ attach(elements: Map): void { for (const [key, el] of elements.entries()) { if (!VALIDATING_FIELDS.includes(key)) { continue; } // Each listener closes over `key`, so we know which field actually fired // the event — even though Clover's payload carries state for ALL fields. el.addEventListener('change', (event: Event) => this.onCloverEvent(event, 'change', key)); el.addEventListener('blur', (event: Event) => this.onCloverEvent(event, 'blur', key)); } } /** * Overlay tokenize-time errors onto per-field `displayedError`. Used when * `clover.createToken()` returns errors at submit time — we route them * back to the firing fields instead of joining them into a generic banner. * * For each field that errored we also set `hasBlurred = true`, so the * existing live-error-clearing rule kicks in: as soon as the user edits the * field, the next `change` event recomputes `displayedError` from Clover's * current state and clears the submit error. * * The Clover-side empty-vs-valid ambiguity for `CARD_NAME` / `CARD_EMAIL_ADDRESS` * means createToken may or may not emit errors for those fields when empty; * we surface whatever Clover reports and leave the rest as-is. */ setSubmitErrors(errors: Partial>): void { let changed = false; for (const [cloverKey, fieldKey] of Object.entries(CLOVER_KEY_TO_FIELD_KEY)) { const message = errors[cloverKey as CloverElementType]; if (!message) { continue; } const prev = this.state.get(fieldKey) ?? emptyFieldState(); const next: InternalFieldState = { ...prev, hasBlurred: true, displayedError: message, }; if ( prev.hasBlurred !== next.hasBlurred || prev.displayedError !== next.displayedError ) { this.state.set(fieldKey, next); changed = true; } } if (changed) { this.emit(); } } subscribe(listener: (snapshot: ValidationSnapshot) => void): Unsubscribe { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } getSnapshot(): ValidationSnapshot { const fields: { [K in FieldKey]?: FieldValidationState } = {}; for (const [key, val] of this.state.entries()) { fields[key] = toPublic(val); } return { fields, canSubmit: this.computeCanSubmit() }; } private onCloverEvent(event: Event, eventType: CloverEventType, ownerKey: FieldKey): void { const ev = event as unknown as Record; let changed = false; for (const [cloverKey, fieldKey] of Object.entries(CLOVER_KEY_TO_FIELD_KEY)) { const cloverState = ev[cloverKey]; if (!cloverState) { continue; } const prev = this.state.get(fieldKey) ?? emptyFieldState(); const next: InternalFieldState = { ...prev, touched: cloverState.touched, error: isMeaningfulError(cloverState.error) ? cloverState.error : undefined, }; // Only credit the field that ACTUALLY fired the event with the // "user did something" signal. Other fields' states in the payload are // passive snapshots — they shouldn't flip hadChangeEver/hasBlurred. const isOwnEvent = fieldKey === ownerKey; if (isOwnEvent && eventType === 'change') { next.hadChangeEver = true; } if (isOwnEvent && eventType === 'blur') { next.hasBlurred = true; } // Recompute displayedError. if (eventType === 'blur' || next.hasBlurred) { // Either the field just blurred, or it has blurred at least once and // a change is updating the live displayed error. next.displayedError = computeDisplayedError(fieldKey, next, this.required); } else { // Pre-first-blur change → don't surface errors yet (deferred-error rule). next.displayedError = undefined; } if ( prev.touched !== next.touched || prev.error !== next.error || prev.hasBlurred !== next.hasBlurred || prev.hadChangeEver !== next.hadChangeEver || prev.displayedError !== next.displayedError ) { this.state.set(fieldKey, next); changed = true; } } if (changed) { this.emit(); } } private computeCanSubmit(): boolean { for (const field of this.required) { const s = this.state.get(field); if (!s || !s.touched || s.error) { return false; } // For cardName/cardEmail, Clover reports no error when empty (it doesn't // enforce required for these). Layer our own check: require at least one // change event before we'll let the form submit. if (REQUIRES_NON_EMPTY_OVERRIDE.has(field) && !s.hadChangeEver) { return false; } } return true; } private emit(): void { const snapshot = this.getSnapshot(); for (const listener of this.listeners) { listener(snapshot); } } } /** * Decide what error string (if any) the UI should render for `fieldKey`. Runs * after raw state is reconciled. Returns undefined when no error should show. */ function computeDisplayedError( fieldKey: FieldKey, state: InternalFieldState, required: ReadonlySet, ): string | undefined { if (state.error) { return state.error; } // No Clover error reported. For cardName/cardEmail specifically — which // Clover doesn't validate as required — emit "Required" on blur if the // field is required and the user never typed. if ( state.hasBlurred && !state.hadChangeEver && required.has(fieldKey) && REQUIRES_NON_EMPTY_OVERRIDE.has(fieldKey) ) { return 'Required'; } return undefined; }