Skip to content

feat: add prebuilt PrimerCardForm and payment outcome flow (ACC-6495)#349

Draft
OnurVar wants to merge 1 commit intoov/feat/ACC-6494from
ov/feat/ACC-6495
Draft

feat: add prebuilt PrimerCardForm and payment outcome flow (ACC-6495)#349
OnurVar wants to merge 1 commit intoov/feat/ACC-6494from
ov/feat/ACC-6495

Conversation

@OnurVar
Copy link
Copy Markdown
Contributor

@OnurVar OnurVar commented Apr 20, 2026

Summary

  • Add <PrimerCardForm /> — drop-in card form composing useCardForm() + the four card inputs with an internal focus chain (card → expiry → CVV → name → submit) and submit button. Exposes onSubmitStart, autoFocus, style, testID.
  • Route paymentOutcome through <CheckoutFlow />: success auto-dismisses the sheet after 5s, error lands on the retry screen. Honors isSuccessScreenEnabled / isErrorScreenEnabled to skip screens when disabled.
  • Make the provider's raw-data manager session-scoped, keyed by activeMethod (set by the method-selection screen rather than the view lifecycle). The manager survives the card-form view mount/unmount so retry and post-submit screens keep the same native manager alive.
  • Implement retry on PrimerCheckoutProvider: reconfigure → re-set raw data → submit. The reconfigure is a workaround for ACC-6920 (iOS RawDataManager.swift:237 nullifies its delegate on successful tokenization) — a TODO(iOS native fix) marks it.
  • Replace the sheet's single-value setHeight / resetHeight with a token-based height-request stack. Each request returns a per-token release so a late cleanup from a prior screen can't clobber a newer screen's active request.
  • Make NavigationContainer always render the current screen inside a stable Animated.View wrapper; the outgoing screen is a sibling during transitions. Component instances survive transition start/end — no remount churn, no duplicate useEffect fires.
  • Add CardFormScreen (header + scroll + KeyboardAvoidingView) as the drop-in wrapper; standalone merchants render <PrimerCardForm /> directly.
  • Make RawDataManager.configureListeners() idempotent so reconfigure-on-retry doesn't stack duplicate subscriptions or trigger "no listeners registered" warnings.
  • Add src/Components/internal/debug.ts with a single-line fmt() for console logs.

Out of scope (intentional)

  • Apple Pay / Google Pay / PayPal / Klarna / APM tiles — deferred to M10 / M14.
  • Merchant-customizable success/error screens — current screens are the default; full customization arrives with the layer-1/layer-3 customization work.
  • iOS native RawDataManager delegate fix — tracked separately; retry workaround stays until the native change lands.

Jira

ACC-6495

Test plan

  • yarn typecheck — clean
  • yarn lint — clean
  • yarn test — 91/91 passing
  • Manual: tap card tile → form opens with card number focused after the slide lands
  • Manual: valid card (4242 4242 4242 4242) → Pay → processing → success → auto-dismiss after 5s
  • Manual: failing card (4000 0000 0000 0002) → Pay → processing → error → Retry re-submits
  • Manual: error screen → "Choose other" → back to method selection
  • Manual: swipe-down / backdrop press dismisses the sheet cleanly

Stacked on

Depends on #347 (ACC-6494 input-chain additions). Merge #347 first, then rebase this onto main.

@OnurVar OnurVar requested a review from a team as a code owner April 20, 2026 00:55
@OnurVar OnurVar self-assigned this Apr 20, 2026
@OnurVar OnurVar marked this pull request as draft April 20, 2026 00:56
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 20, 2026

Warnings
⚠️ This PR doesn't seem to contain any updated Unit Test for Swift 🤔. Please consider double checking it 🙏
Messages
📖 ✅ No SwiftLint violations found.

Generated by 🚫 Danger Swift against fe457d7

Copy link
Copy Markdown

@unblocked unblocked Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found.

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions about your code.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

Comment on lines +71 to +72
} else if (id.includes('cardholder') || id.includes('name')) {
fieldErrors.cardholderName = description;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check id.includes('name') will match any error whose errorId contains the substring "name" — e.g. "payment_method_name_invalid", "username_required", etc. — and assign it to the cardholderName field, even if it's unrelated.

Consider tightening to match specifically on cardholder-related IDs:

} else if (id.includes('cardholder') || id.includes('card_holder') || id.includes('cardholder_name')) {
    fieldErrors.cardholderName = description;
}

Comment on lines +280 to +338
useEffect(() => {
if (!state.isReady || !state.activeMethod) {
return;
}

const method = state.activeMethod;
let cancelled = false;
const m = new PrimerHeadlessUniversalCheckoutRawDataManager();
managerRef.current = m;

const callbacks = {
onValidation: (isValid: boolean, errors: PrimerError[] | undefined) => {
const parsed = parseValidationErrors(errors);
setState((prev) => ({
...prev,
cardFormState: { ...prev.cardFormState, isValid, errors: parsed },
}));
},
onBinDataChange: (binData: PrimerBinData) => {
setState((prev) => ({
...prev,
cardFormState: { ...prev.cardFormState, binData },
}));
},
onMetadataChange: (metadata: unknown) => {
setState((prev) => ({
...prev,
cardFormState: { ...prev.cardFormState, metadata },
}));
},
};
lastManagerCallbacksRef.current = callbacks;

(async () => {
try {
await m.configure({ paymentMethodType: method, ...callbacks });
if (cancelled) return;
const requiredFields = await m.getRequiredInputElementTypes();
if (cancelled) return;
setState((prev) => ({
...prev,
cardFormState: { ...prev.cardFormState, requiredFields },
}));
} catch (err) {
console.error(`${LOG} manager configure failed ${fmt(err)}`);
}
})();

return () => {
cancelled = true;
m.cleanUp().catch((err) => console.warn(`${LOG} manager cleanUp failed ${fmt(err)}`));
m.removeAllListeners();
if (managerRef.current === m) {
managerRef.current = null;
}
lastManagerCallbacksRef.current = null;
setState((prev) => ({ ...prev, cardFormState: initialCardFormState }));
};
}, [state.activeMethod, state.isReady]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The raw-data manager lifecycle effect lists [state.activeMethod, state.isReady] as dependencies. Any setState call that touches other InternalState fields (e.g. paymentOutcome, cardFormState) won't change these booleans/strings, so this is safe in most paths.

However, the retry() callback at R392 calls setState((prev) => ({ ...prev, paymentOutcome: null })). If this setState were ever batched with a state update that also flips isReady or activeMethod (e.g. on session expiry during a retry), the effect cleanup would destroy the manager (m.cleanUp()) while retry() is mid-flight — m.submit() at R393 would then operate on a cleaned-up manager.

This is unlikely in normal flows but could produce a hard-to-diagnose crash on session timeout during retry. Consider adding a guard in the cleanup that skips cleanUp() while a retry is in progress (e.g. via an isRetryingRef).

Comment on lines +27 to +31
const onRetry = () => {
// Navigate to processing first so the user sees an immediate spinner;
// the outcome transitioner replaces with success/error when retry resolves.
replace(CheckoutRoute.processing);
retry().catch((err) => console.warn(`${LOG} retry error ${fmt(err)}`));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

retry() can throw on the m.configure() or m.setRawData() steps (R394-R396 in the provider). Those failures are JS-side errors that never reach the native onError callback, so paymentOutcome is never set to error and the user is stuck on the processing spinner indefinitely.

The .catch() here only logs a warning. Consider navigating back to the error screen (or re-setting paymentOutcome) in the catch handler:

retry().catch((err) => {
  console.warn(`${LOG} retry error ${fmt(err)}`);
  replace(CheckoutRoute.error, { error: err });
});

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 20, 2026

Warnings
⚠️ This PR doesn't seem to contain any updated Unit Test for Kotlin 🤔. Please consider double checking it 🙏
Messages
📖 ✅ No detekt violations found.

Generated by 🚫 Danger Kotlin against fe457d7

@github-actions
Copy link
Copy Markdown

Appetize Android link: https://appetize.io/app/oq65lou2vntonwbnx4mmk6mjeq

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 20, 2026

Appetize iOS link: https://appetize.io/app/pas7jbw3n54kuft6a4hzzk43b4

const SUCCESS_AUTO_DISMISS_MS = 5000;

function CheckoutLoadingScreen() {
return <LoadingScreen title="Loading your secure checkout" subtitle="This won't take long" />;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commented on this is earlier, if this is fresher then my other comment can be ignored

@github-actions
Copy link
Copy Markdown

Warnings
⚠️ Pull Request size seems relatively large. If this Pull Request contains multiple changes, please split each into separate PRs for a faster, easier review.
⚠️ PR is classed as Work in Progress
Messages
📖 ✅ No ESLint violations found.

Generated by 🚫 dangerJS against fe457d7

@OnurVar OnurVar force-pushed the ov/feat/ACC-6495 branch 2 times, most recently from 60142f0 to 161509c Compare April 24, 2026 01:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants