diff --git a/.changeset/olive-buttons-hide.md b/.changeset/olive-buttons-hide.md new file mode 100644 index 000000000..70161a9ee --- /dev/null +++ b/.changeset/olive-buttons-hide.md @@ -0,0 +1,8 @@ +--- +"@crossmint/client-sdk-base": major +"@crossmint/client-sdk-react-ui": major +--- + +BREAKING: `CrossmintPaymentMethodManagement` no longer renders a built-in submit button. The `hideSubmitButton` prop is removed — the component always asks the host to submit via `ref.submit()`, which must be wired to your own button. The iframe no longer draws the message of an `invalid` or `failed` submit: show the `message` that `ref.submit()` returns. Field errors stay in the iframe. + +`CrossmintPaymentMethodManagement` adds `onReady` and `onChange({ mode, empty, complete })`, like Stripe's `ready` and `change` events, so your button can stay disabled until a submit can succeed. `invalid`, `failed` and `unavailable` submit results now carry a `code` (for example `incomplete_card`, `save_failed`, `timeout`); a code the component does not know becomes `unknown`. The code lists are exported as `PAYMENT_METHOD_SUBMIT_INVALID_CODES`, `PAYMENT_METHOD_SUBMIT_FAILED_CODES` and `PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES`. diff --git a/apps/payments/nextjs/app/payment-method-management/page.tsx b/apps/payments/nextjs/app/payment-method-management/page.tsx index b862d44cf..ad3acea35 100644 --- a/apps/payments/nextjs/app/payment-method-management/page.tsx +++ b/apps/payments/nextjs/app/payment-method-management/page.tsx @@ -6,6 +6,7 @@ import { useCrossmintAuth, CrossmintPaymentMethodManagement, type CrossmintPaymentMethodManagementRef, + type PaymentMethodManagementChangeEvent, type PaymentMethodSubmitResult, } from "@crossmint/client-sdk-react-ui"; import { useRef, useState } from "react"; @@ -22,7 +23,8 @@ export default function PaymentMethodManagementPage() { function PaymentMethodManagementWrapper() { const { jwt } = useCrossmintAuth(); const [submitting, setSubmitting] = useState(false); - const [ownButton, setOwnButton] = useState(false); + const [ready, setReady] = useState(false); + const [formState, setFormState] = useState(null); const [collectBillingAddress, setCollectBillingAddress] = useState(false); const [billingLine2, setBillingLine2] = useState<"optional" | "required" | "hidden">("optional"); const [result, setResult] = useState(null); @@ -44,10 +46,6 @@ function PaymentMethodManagementWrapper() { return (
-
); diff --git a/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.test.ts b/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.test.ts index 7c60a681f..90560136a 100644 --- a/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.test.ts +++ b/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.test.ts @@ -55,20 +55,18 @@ describe("paymentMethodManagementService.getUrl", () => { expect(params.has("allowedPaymentMethodTypes")).toBe(false); }); - it("carries hideSubmitButton only when the host sets it, so the iframe default applies otherwise", () => { + it("always hides the iframe submit button (the host submits via ref.submit())", () => { const service = makeService(); - expect(new URL(service.iframe.getUrl({ jwt: "jwt-token" })).searchParams.has("hideSubmitButton")).toBe(false); - expect( - new URL(service.iframe.getUrl({ jwt: "jwt-token", hideSubmitButton: false })).searchParams.has( - "hideSubmitButton" - ) - ).toBe(false); - expect( - new URL(service.iframe.getUrl({ jwt: "jwt-token", hideSubmitButton: true })).searchParams.get( - "hideSubmitButton" - ) - ).toBe("true"); + const url = service.iframe.getUrl({ jwt: "jwt-token" }); + expect(new URL(url).searchParams.get("hideSubmitButton")).toBe("true"); + }); + + it("tells the iframe that the host shows submit errors, so the message is not drawn twice", () => { + const service = makeService(); + + const url = service.iframe.getUrl({ jwt: "jwt-token" }); + expect(new URL(url).searchParams.get("hostShowsSubmitErrors")).toBe("true"); }); it("carries fields only when the host sets it, so the iframe default applies otherwise", () => { diff --git a/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.ts b/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.ts index 9f8647aaf..8d021b00c 100644 --- a/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.ts +++ b/packages/client/base/src/services/payment-method-management/paymentMethodManagementService.ts @@ -118,6 +118,11 @@ export function createPaymentMethodManagementService({ apiClient }: PaymentMetho appendObjectToQueryParams(queryParams, serializableProps); + // The component never renders a submit button; the host submits via ref.submit(). + queryParams.append("hideSubmitButton", "true"); + // The host gets the message of an invalid or failed submit from ref.submit() and shows it, so the iframe + // draws only field errors and does not show the message a second time. + queryParams.append("hostShowsSubmitErrors", "true"); queryParams.append("apiKey", apiClient.crossmint.apiKey); queryParams.append("sdkMetadata", JSON.stringify(apiClient["internalConfig"].sdkMetadata)); diff --git a/packages/client/base/src/types/payment-method-management/CrossmintPaymentMethodManagementProps.ts b/packages/client/base/src/types/payment-method-management/CrossmintPaymentMethodManagementProps.ts index e18362426..4eab7a9af 100644 --- a/packages/client/base/src/types/payment-method-management/CrossmintPaymentMethodManagementProps.ts +++ b/packages/client/base/src/types/payment-method-management/CrossmintPaymentMethodManagementProps.ts @@ -56,11 +56,6 @@ export interface CrossmintPaymentMethodManagementProps { * than one type does not yet render a type picker. */ allowedPaymentMethodTypes?: PaymentMethodManagementAllowedType[]; - /** - * Render no Save/Continue button inside the component (default `false`). Pair it with a - * `ref` and call `ref.current.submit()` from your own button. - */ - hideSubmitButton?: boolean; /** * Optional fields of the add-card form, in Stripe's `billingDetails` shape so new * fields need no new prop. `billingDetails.address: true` collects a required @@ -88,8 +83,51 @@ export interface CrossmintPaymentMethodManagementProps { */ defaultValues?: PaymentMethodManagementDefaultValues | null; onPaymentMethodSelected?: (paymentMethod: CrossmintPaymentMethod) => void | Promise; + /** + * Fires when `ref.submit()` can first select or save, like Stripe's `ready` event. Until then a + * submit answers `unavailable`. It fires again after the iframe reloads, for example when `jwt` + * or the form configuration changes. + */ + onReady?: () => void; + /** + * Fires after `onReady` and whenever the form state changes, like Stripe's `change` event. Use + * `complete` to enable your submit button. When a prop change reloads the iframe, it fires with + * `complete: false` until the new form reports its own state. + */ + onChange?: (event: PaymentMethodManagementChangeEvent) => void; } +/** + * The form state `onChange` reports. + * - `mode`: `"existing"` while a saved method is selected, `"new"` while the add-new form shows. + * - `complete`: a submit would select or save without a field error. + * - `empty`: the user entered nothing in the add-new form. Prefilled `defaultValues` do not count. + */ +export type PaymentMethodManagementChangeEvent = { + mode: "new" | "existing"; + empty: boolean; + complete: boolean; +}; + +/** + * Why a submit did not select or save, for code that branches on the reason or shows its own text. + * Each list is the single definition of its codes: the types below and the result validation both + * come from it. `unknown` means the component sent no known code. + */ +export const PAYMENT_METHOD_SUBMIT_INVALID_CODES = [ + "incomplete_card", + "invalid_billing_address", + "incomplete_bank_account", + "unknown", +] as const; +export const PAYMENT_METHOD_SUBMIT_FAILED_CODES = ["save_failed", "select_failed", "unknown"] as const; +/** `timeout` comes from the SDK: the component did not answer in time. */ +export const PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES = ["not_ready", "timeout", "unknown"] as const; + +export type PaymentMethodSubmitInvalidCode = (typeof PAYMENT_METHOD_SUBMIT_INVALID_CODES)[number]; +export type PaymentMethodSubmitFailedCode = (typeof PAYMENT_METHOD_SUBMIT_FAILED_CODES)[number]; +export type PaymentMethodSubmitUnavailableCode = (typeof PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES)[number]; + /** * What `ref.submit()` resolves to. It never rejects. * - `selected`: the user had a saved method selected; it is the one to use. @@ -101,13 +139,16 @@ export interface CrossmintPaymentMethodManagementProps { * `onPaymentMethodSelected` fires if it did, and submitting again returns the same method. * - `superseded`: the component changed or unmounted while submitting. Discard the result; * this does not cancel a save already started on the server. + * + * `invalid`, `failed` and `unavailable` carry a `code` (see the `PaymentMethodSubmit*Code` types) + * and a `message` to show the user. The component shows only field errors itself. */ export type PaymentMethodSubmitResult = | { status: "selected"; paymentMethod: CrossmintPaymentMethod } | { status: "saved"; paymentMethod: CrossmintPaymentMethod } - | { status: "invalid"; message: string } - | { status: "failed"; message: string } - | { status: "unavailable"; message: string } + | { status: "invalid"; code: PaymentMethodSubmitInvalidCode; message: string } + | { status: "failed"; code: PaymentMethodSubmitFailedCode; message: string } + | { status: "unavailable"; code: PaymentMethodSubmitUnavailableCode; message: string } | { status: "superseded"; message: string }; export interface CrossmintPaymentMethodManagementRef { diff --git a/packages/client/base/src/types/payment-method-management/events/incoming.test.ts b/packages/client/base/src/types/payment-method-management/events/incoming.test.ts index 0808b3392..26dfbabd7 100644 --- a/packages/client/base/src/types/payment-method-management/events/incoming.test.ts +++ b/packages/client/base/src/types/payment-method-management/events/incoming.test.ts @@ -1,4 +1,9 @@ import { describe, expect, it } from "vitest"; +import { + PAYMENT_METHOD_SUBMIT_FAILED_CODES, + PAYMENT_METHOD_SUBMIT_INVALID_CODES, + PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES, +} from "../CrossmintPaymentMethodManagementProps"; import { paymentMethodManagementIncomingEvents } from "./incoming"; const resultSchema = paymentMethodManagementIncomingEvents["payment-method:result"]; @@ -23,7 +28,7 @@ describe("payment-method:result schema", () => { } expect( resultSchema.parse({ requestId: "r1", result: { status: "invalid", message: "Check the card" } }).result - ).toEqual({ status: "invalid", message: "Check the card" }); + ).toEqual({ status: "invalid", code: "unknown", message: "Check the card" }); expect( resultSchema.safeParse({ requestId: "", result: { status: "saved", paymentMethod: CARD } }).success ).toBe(false); @@ -32,15 +37,76 @@ describe("payment-method:result schema", () => { it.each([ [ { status: "requires_action", message: "x" }, - { status: "failed", message: "x" }, + { status: "failed", code: "unknown", message: "x" }, ], - [{ status: "requires_action" }, { status: "failed", message: FALLBACK_MESSAGE }], - [{ status: "saved" }, { status: "failed", message: FALLBACK_MESSAGE }], + [{ status: "requires_action" }, { status: "failed", code: "unknown", message: FALLBACK_MESSAGE }], + [{ status: "saved" }, { status: "failed", code: "unknown", message: FALLBACK_MESSAGE }], [ { status: "selected", paymentMethod: {} }, - { status: "failed", message: FALLBACK_MESSAGE }, + { status: "failed", code: "unknown", message: FALLBACK_MESSAGE }, ], ])("maps %j to %j instead of dropping it", (result, expected) => { expect(resultSchema.parse({ requestId: "r1", result }).result).toEqual(expected); }); }); + +describe("submit result codes", () => { + it.each([ + ["invalid", "incomplete_card"], + ["invalid", "invalid_billing_address"], + ["invalid", "incomplete_bank_account"], + ["failed", "save_failed"], + ["failed", "select_failed"], + ["unavailable", "not_ready"], + ])("keeps the %s code %s", (status, code) => { + expect(resultSchema.parse({ requestId: "r1", result: { status, code, message: "m" } }).result).toEqual({ + status, + code, + message: "m", + }); + }); + + it("turns a missing or unknown code into unknown and keeps the result", () => { + expect( + resultSchema.parse({ requestId: "r1", result: { status: "failed", code: "card_declined", message: "m" } }) + .result + ).toEqual({ status: "failed", code: "unknown", message: "m" }); + expect(resultSchema.parse({ requestId: "r1", result: { status: "unavailable", message: "m" } }).result).toEqual( + { status: "unavailable", code: "unknown", message: "m" } + ); + }); +}); + +describe("published code lists", () => { + it.each([ + ["invalid", PAYMENT_METHOD_SUBMIT_INVALID_CODES], + ["failed", PAYMENT_METHOD_SUBMIT_FAILED_CODES], + ["unavailable", PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES], + ] as const)("keeps every published %s code", (status, codes) => { + for (const code of codes) { + expect(resultSchema.parse({ requestId: "r1", result: { status, code, message: "m" } }).result).toEqual({ + status, + code, + message: "m", + }); + } + }); +}); + +describe("form state events", () => { + const changeSchema = paymentMethodManagementIncomingEvents["payment-method:change"]; + + it("accepts ready and a complete change", () => { + expect(paymentMethodManagementIncomingEvents["payment-method:ready"].safeParse({}).success).toBe(true); + expect(changeSchema.parse({ mode: "new", empty: false, complete: true })).toEqual({ + mode: "new", + empty: false, + complete: true, + }); + }); + + it("rejects a change without its state", () => { + expect(changeSchema.safeParse({ mode: "new" }).success).toBe(false); + expect(changeSchema.safeParse({ mode: "other", empty: true, complete: false }).success).toBe(false); + }); +}); diff --git a/packages/client/base/src/types/payment-method-management/events/incoming.ts b/packages/client/base/src/types/payment-method-management/events/incoming.ts index 55e2f6ed4..7e286081a 100644 --- a/packages/client/base/src/types/payment-method-management/events/incoming.ts +++ b/packages/client/base/src/types/payment-method-management/events/incoming.ts @@ -1,16 +1,35 @@ import { z } from "zod"; +import { + PAYMENT_METHOD_SUBMIT_FAILED_CODES, + PAYMENT_METHOD_SUBMIT_INVALID_CODES, + PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES, + type PaymentMethodManagementChangeEvent, +} from "../CrossmintPaymentMethodManagementProps"; // `paymentMethod` is the same shape `payment-method:selected` carries (CrossmintPaymentMethod); // only the fields consumers narrow on are enforced, extra fields pass through. const paymentMethodSchema = z.object({ type: z.string(), paymentMethodId: z.string() }).passthrough(); +// A missing or unknown `code` (an older or newer iframe) becomes "unknown"; the result still resolves. const paymentMethodSubmitResultSchema = z.union([ z.discriminatedUnion("status", [ z.object({ status: z.literal("selected"), paymentMethod: paymentMethodSchema }), z.object({ status: z.literal("saved"), paymentMethod: paymentMethodSchema }), - z.object({ status: z.literal("invalid"), message: z.string() }), - z.object({ status: z.literal("failed"), message: z.string() }), - z.object({ status: z.literal("unavailable"), message: z.string() }), + z.object({ + status: z.literal("invalid"), + code: z.enum(PAYMENT_METHOD_SUBMIT_INVALID_CODES).catch("unknown"), + message: z.string(), + }), + z.object({ + status: z.literal("failed"), + code: z.enum(PAYMENT_METHOD_SUBMIT_FAILED_CODES).catch("unknown"), + message: z.string(), + }), + z.object({ + status: z.literal("unavailable"), + code: z.enum(PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES).catch("unknown"), + message: z.string(), + }), ]), // A future iframe status still resolves instead of hanging the host until the submit timeout. z @@ -18,6 +37,7 @@ const paymentMethodSubmitResultSchema = z.union([ .passthrough() .transform((result) => ({ status: "failed" as const, + code: "unknown" as const, message: result.message ?? "The payment method component returned an unexpected result. Please try again.", })), ]); @@ -27,6 +47,13 @@ export const paymentMethodManagementIncomingEvents = { height: z.number(), }), "payment-method:selected": z.any(), + // Once per iframe document, when a submit can first select or save. + "payment-method:ready": z.object({}).passthrough(), + "payment-method:change": z.object({ + mode: z.enum(["new", "existing"]), + empty: z.boolean(), + complete: z.boolean(), + }) satisfies z.ZodType, "payment-method:result": z.object({ requestId: z.string().min(1), result: paymentMethodSubmitResultSchema, diff --git a/packages/client/ui/react-ui/examples.json b/packages/client/ui/react-ui/examples.json index d9c2e7e03..6d58dc8aa 100644 --- a/packages/client/ui/react-ui/examples.json +++ b/packages/client/ui/react-ui/examples.json @@ -81,9 +81,9 @@ "language": "tsx" }, "CrossmintPaymentMethodManagement": { - "code": "import { CrossmintPaymentMethodManagement } from \"@crossmint/client-sdk-react-ui\";\n\nfunction PaymentMethods({ jwt }: { jwt: string }) {\n return (\n {\n if (paymentMethod.type === \"card\") {\n console.log(\"Card selected:\", paymentMethod.card.last4);\n }\n }}\n />\n );\n}", + "code": "import { useRef, useState } from \"react\";\nimport {\n CrossmintPaymentMethodManagement,\n type CrossmintPaymentMethodManagementRef,\n} from \"@crossmint/client-sdk-react-ui\";\n\nfunction PaymentMethods({ jwt }: { jwt: string }) {\n const ref = useRef(null);\n const [ready, setReady] = useState(false);\n const [complete, setComplete] = useState(false);\n const [submitting, setSubmitting] = useState(false);\n const [error, setError] = useState(null);\n\n async function submit() {\n setSubmitting(true);\n const result = await ref.current?.submit();\n setSubmitting(false);\n if (result?.status === \"invalid\" || result?.status === \"failed\" || result?.status === \"unavailable\") {\n setError(result.message);\n } else {\n setError(null);\n }\n }\n\n return (\n <>\n setReady(true)}\n onChange={(event) => {\n setComplete(event.complete);\n setError(null);\n }}\n onPaymentMethodSelected={(paymentMethod) => {\n if (paymentMethod.type === \"card\") {\n console.log(\"Card selected:\", paymentMethod.card.last4);\n }\n }}\n />\n \n {error != null &&

{error}

}\n \n );\n}", "language": "tsx", - "note": "CrossmintPaymentMethodManagement renders an iframe-based UI for saved payment methods. Pass a user JWT from your own auth provider or a custom JWT. Crossmint Auth JWTs are not supported. The onPaymentMethodSelected callback receives a payment-method union. Check `type` before you use its fields." + "note": "CrossmintPaymentMethodManagement renders an iframe-based UI for saved payment methods. Pass a user JWT from your own auth provider or a custom JWT. Crossmint Auth JWTs are not supported. The onPaymentMethodSelected callback receives a payment-method union. Check `type` before you use its fields. The component renders no submit button: render your own and call `ref.current.submit()` from it. `onReady` and `onChange` tell you when to enable it. Show the `message` of an `invalid`, `failed` or `unavailable` result; the iframe shows only field errors. Each of these results also carries a `code`." }, "CrossmintProtectedInput": { "code": "import { useRef, useState } from \"react\";\nimport {\n CrossmintProvider,\n CrossmintProtectedInput,\n type CrossmintProtectedInputRef,\n type ProtectedInputField,\n} from \"@crossmint/client-sdk-react-ui\";\n\ntype Props = {\n apiKey: string;\n jwt: string;\n field: ProtectedInputField;\n onCollected: (answer: { protectedInputId: string }) => Promise;\n};\n\nexport function ProtectedField({ apiKey, jwt, field, onCollected }: Props) {\n const ref = useRef(null);\n const [busy, setBusy] = useState(false);\n const [error, setError] = useState(\"\");\n\n async function collect() {\n if (busy || ref.current === null) {\n return;\n }\n setBusy(true);\n setError(\"\");\n try {\n const result = await ref.current.collect();\n if (result.status !== \"collected\") {\n setError(result.message);\n return;\n }\n await onCollected(result.input);\n } catch {\n setError(\"Could not confirm submission. Retry the same message.\");\n } finally {\n setBusy(false);\n }\n }\n\n return (\n \n
{field.label}
\n \n {error !== \"\" &&

{error}

}\n \n
\n );\n}", diff --git a/packages/client/ui/react-ui/src/components/agent-card-authorization/CrossmintAgentCardAuthorization.tsx b/packages/client/ui/react-ui/src/components/agent-card-authorization/CrossmintAgentCardAuthorization.tsx index e41856ce8..6a2472223 100644 --- a/packages/client/ui/react-ui/src/components/agent-card-authorization/CrossmintAgentCardAuthorization.tsx +++ b/packages/client/ui/react-ui/src/components/agent-card-authorization/CrossmintAgentCardAuthorization.tsx @@ -1,6 +1,10 @@ -import type { CrossmintAgentCardAuthorizationProps } from "@crossmint/client-sdk-base"; +import type { + CrossmintAgentCardAuthorizationProps, + CrossmintPaymentMethodManagementRef, +} from "@crossmint/client-sdk-base"; -import { CrossmintPaymentMethodManagement } from "../card-management"; +import { useRef, useState } from "react"; +import { CrossmintPaymentMethodManagement } from "../card-management/CrossmintPaymentMethodManagement"; import { CrossmintCvcRecollection, OrderIntentVerification } from "../order-intent"; import { useAgentCardAuthorization } from "./useAgentCardAuthorization"; @@ -47,13 +51,82 @@ export function CrossmintAgentCardAuthorization(props: CrossmintAgentCardAuthori ); } + return ; +} + +// The payment-method iframe renders no button, so this step owns the submit: the button calls +// `ref.submit()`, which also emits `payment-method:selected` and drives `onPaymentMethodSelected`. +function CardSelectionStep({ + props, + jwt, + onPaymentMethodSelected, +}: { + props: CrossmintAgentCardAuthorizationProps; + jwt: string; + onPaymentMethodSelected: (paymentMethod: unknown) => void; +}) { + const pmm = useRef(null); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + async function submit() { + setSubmitting(true); + setSubmitError(null); + try { + const result = await pmm.current?.submit(); + if ( + result != null && + result.status !== "selected" && + result.status !== "saved" && + result.status !== "superseded" + ) { + setSubmitError(result.message); + } + } finally { + setSubmitting(false); + } + } + + const primaryButton = props.appearance?.paymentMethodManagement?.rules?.PrimaryButton; + const background = submitting + ? primaryButton?.disabled?.colors?.background ?? "#e7e9ed" + : primaryButton?.colors?.background ?? "#05B959"; + const color = submitting ? "#67797f" : primaryButton?.colors?.text ?? "#ffffff"; + return ( - +
+ + + {submitError != null && ( +

+ {submitError} +

+ )} +
); } diff --git a/packages/client/ui/react-ui/src/components/agent-card-authorization/crossmint-agent-card-authorization.test.tsx b/packages/client/ui/react-ui/src/components/agent-card-authorization/crossmint-agent-card-authorization.test.tsx index 3c4a65e2d..d87831855 100644 --- a/packages/client/ui/react-ui/src/components/agent-card-authorization/crossmint-agent-card-authorization.test.tsx +++ b/packages/client/ui/react-ui/src/components/agent-card-authorization/crossmint-agent-card-authorization.test.tsx @@ -4,7 +4,7 @@ import { ApiClient } from "@crossmint/common-sdk-base"; import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { useEffect, useRef } from "react"; +import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { CrossmintAgentCardAuthorization } from "./CrossmintAgentCardAuthorization"; import { RAIL_POLL_INTERVAL_MS, RAIL_POLL_TIMEOUT_MS } from "./useAgentCardAuthorization"; @@ -20,14 +20,17 @@ const stubs = vi.hoisted(() => ({ // Like the real iframe wrapper, the stub keeps the props of its first render for the // callbacks (it subscribes once), so a stale closure in the hook would surface here. The // rendered jwt attribute follows the live prop, like the real iframe src would. -vi.mock("../card-management", () => ({ - CrossmintPaymentMethodManagement: (props: Record) => { +vi.mock("../card-management/CrossmintPaymentMethodManagement", () => ({ + CrossmintPaymentMethodManagement: forwardRef((props: Record, ref) => { const firstProps = useRef(props); useEffect(() => { stubs.pmm.props = firstProps.current as Record void>; }, []); + useImperativeHandle(ref, () => ({ + submit: () => Promise.resolve({ status: "unavailable", message: "stub" }), + })); return
; - }, + }), })); vi.mock("../order-intent", () => ({ diff --git a/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagement.tsx b/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagement.tsx index 23103add8..2239a30c2 100644 --- a/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagement.tsx +++ b/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagement.tsx @@ -12,9 +12,9 @@ import { forwardRef } from "react"; * `onPaymentMethodSelected` receives the chosen method. Your backend uses its `paymentMethodId` to pay. * It can use that ID to create order intents. * - * To use your own button instead of the component's, pass `hideSubmitButton` and a `ref`, and - * call `ref.current.submit()` from your button: it resolves with the selected or saved method, - * or with why the submit could not complete. + * The component renders no submit button. Render your own and call `ref.current.submit()` + * from it: it resolves with the selected or saved method, or with why the submit could not + * complete. */ export const CrossmintPaymentMethodManagement = forwardRef< CrossmintPaymentMethodManagementRef, diff --git a/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagementIFrame.tsx b/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagementIFrame.tsx index 07d38e54e..b2fe54dc4 100644 --- a/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagementIFrame.tsx +++ b/packages/client/ui/react-ui/src/components/card-management/CrossmintPaymentMethodManagementIFrame.tsx @@ -2,6 +2,7 @@ import { createCrossmintApiClient } from "@/utils/createCrossmintApiClient"; import { type CrossmintPaymentMethodManagementProps, type CrossmintPaymentMethodManagementRef, + type PaymentMethodManagementChangeEvent, type PaymentMethodManagementIFrameEmitter, type PaymentMethodSubmitResult, createPaymentMethodManagementService, @@ -14,7 +15,11 @@ const useCommittedLayoutEffect = typeof window === "undefined" ? useEffect : use // A save is two Basis Theory tokenizations, the create call and the card art lookup. const SUBMIT_TIMEOUT_MS = 60_000; -const unavailable = (message: string): PaymentMethodSubmitResult => ({ status: "unavailable", message }); +const unavailable = (code: "not_ready" | "timeout", message: string): PaymentMethodSubmitResult => ({ + status: "unavailable", + code, + message, +}); const superseded = (): PaymentMethodSubmitResult => ({ status: "superseded", message: "The payment method component changed. Please submit again.", @@ -36,6 +41,19 @@ export const CrossmintPaymentMethodManagementIFrame = forwardRef< // The height survives a `key={url}` remount: the last reported height applies to the // new iframe until it reports its own. const [height, setHeight] = useState(0); + // The last state the host got. A new URL reloads the form, so a host that enabled its button on `complete` + // gets `complete: false` until the new document reports its own state. + const lastChange = useRef(null); + const latestOnChange = useRef(props.onChange); + latestOnChange.current = props.onChange; + const previousUrl = useRef(url); + useEffect(() => { + if (previousUrl.current === url) { + return; + } + previousUrl.current = url; + reportReload(lastChange, latestOnChange.current); + }, [url]); // Authentication and form configuration live in the URL. A committed change must // retire the old window too, so its events cannot reach the next buyer's callbacks. return ( @@ -47,10 +65,26 @@ export const CrossmintPaymentMethodManagementIFrame = forwardRef< props={props} height={height} onHeightChange={setHeight} + lastChange={lastChange} /> ); }); +// A reloaded form has not reported yet: a host that enabled its button on `complete` gets `complete: false` until the +// new document reports its own state. +function reportReload( + lastChange: { current: PaymentMethodManagementChangeEvent | null }, + onChange: ((event: PaymentMethodManagementChangeEvent) => void) | undefined +) { + const last = lastChange.current; + if (last?.complete !== true) { + return; + } + const reloading = { ...last, complete: false }; + lastChange.current = reloading; + onChange?.(reloading); +} + const PaymentMethodConnection = forwardRef< CrossmintPaymentMethodManagementRef, { @@ -59,8 +93,9 @@ const PaymentMethodConnection = forwardRef< props: CrossmintPaymentMethodManagementProps; height: number; onHeightChange: (height: number) => void; + lastChange: { current: PaymentMethodManagementChangeEvent | null }; } ->(function PaymentMethodConnection({ url, service, props, height, onHeightChange }, ref) { +>(function PaymentMethodConnection({ url, service, props, height, onHeightChange, lastChange }, ref) { const [iframeService] = useState(service); const iframe = useRef(null); // The first `load` only confirms the mount-time connection; SSR hydration can miss it, @@ -95,6 +130,10 @@ const PaymentMethodConnection = forwardRef< // answers only one handshake per document: share one client (and its single // handshake) per document, while each run still gets a fresh `active`. if (clientRef.current?.reloads !== reloads) { + // The iframe navigated in place: the URL is the same, but the form starts over. + if (reloads > 0) { + reportReload(lastChange, latestProps.current.onChange); + } const client = iframeService.iframe.createClient(iframe.current); // Keep successful readiness, but discard a failed handshake so a later // submit can retry without reloading the iframe. @@ -127,9 +166,22 @@ const PaymentMethodConnection = forwardRef< return latestProps.current.onPaymentMethodSelected?.(method); } }); + const readyListener = client.on("payment-method:ready", () => { + if (connection.current === active) { + latestProps.current.onReady?.(); + } + }); + const changeListener = client.on("payment-method:change", (event) => { + if (connection.current === active) { + lastChange.current = event; + latestProps.current.onChange?.(event); + } + }); return () => { client.off(heightListener); client.off(selectedListener); + client.off(readyListener); + client.off(changeListener); if (pending.current?.client === client) { pending.current.cancel(); pending.current = null; @@ -144,7 +196,7 @@ const PaymentMethodConnection = forwardRef< submit() { const active = connection.current; if (active == null) { - return Promise.resolve(unavailable("The payment method component has not loaded yet.")); + return Promise.resolve(unavailable("not_ready", "The payment method component has not loaded yet.")); } if (pending.current?.client === active.client) { return pending.current.promise; @@ -162,7 +214,7 @@ const PaymentMethodConnection = forwardRef< await active.ready(); } catch { return connection.current === active - ? unavailable("The payment method component did not answer. Please try again.") + ? unavailable("not_ready", "The payment method component did not answer. Please try again.") : superseded(); } if (connection.current !== active) { @@ -185,6 +237,7 @@ const PaymentMethodConnection = forwardRef< // The request was already sent; the save may still have completed. return connection.current === active ? unavailable( + "timeout", "The payment method component did not answer in time. The payment method may still have been saved: onPaymentMethodSelected fires if it was, and submitting again returns it." ) : superseded(); diff --git a/packages/client/ui/react-ui/src/components/card-management/crossmint-payment-method-management.test.tsx b/packages/client/ui/react-ui/src/components/card-management/crossmint-payment-method-management.test.tsx index 841d8391b..09223cb65 100644 --- a/packages/client/ui/react-ui/src/components/card-management/crossmint-payment-method-management.test.tsx +++ b/packages/client/ui/react-ui/src/components/card-management/crossmint-payment-method-management.test.tsx @@ -42,12 +42,7 @@ const SAVED = { status: "saved", paymentMethod: CARD } as const; function mount({ load = true, onSelected }: { load?: boolean; onSelected?: (method: unknown) => void } = {}) { const ref = createRef(); const view = render( - + ); if (load) { fireEvent.load(screen.getByRole("crossmint-payment-method-management.iframe")); @@ -72,8 +67,7 @@ beforeEach(() => { mocks.on.mockReturnValue("subscription"); mocks.createClient.mockImplementation(() => ({ ...mocks })); mocks.getUrl.mockImplementation( - (props) => - `https://staging.crossmint.com/sdk/unstable/payment-method-management?jwt=${props.jwt}&hidden=${props.hideSubmitButton ?? false}` + (props) => `https://staging.crossmint.com/sdk/unstable/payment-method-management?jwt=${props.jwt}&hidden=true` ); }); @@ -127,7 +121,7 @@ describe("CrossmintPaymentMethodManagement ref", () => { mocks.handshakeWithChild.mockRejectedValueOnce(new Error("Handshake timed out")); const { component } = mount(); - await expect(component.submit()).resolves.toMatchObject({ status: "unavailable" }); + await expect(component.submit()).resolves.toMatchObject({ status: "unavailable", code: "not_ready" }); expect(mocks.sendAction).not.toHaveBeenCalled(); mocks.sendAction.mockResolvedValueOnce({ result: SAVED }); @@ -141,6 +135,7 @@ describe("CrossmintPaymentMethodManagement ref", () => { await expect(component.submit()).resolves.toMatchObject({ status: "unavailable", + code: "timeout", message: "The payment method component did not answer in time. The payment method may still have been saved: onPaymentMethodSelected fires if it was, and submitting again returns it.", }); @@ -150,7 +145,7 @@ describe("CrossmintPaymentMethodManagement ref", () => { const ref = createRef(); render( - + ); @@ -221,4 +216,62 @@ describe("CrossmintPaymentMethodManagement ref", () => { act(() => newListener?.({ height: 500 })); expect(newIframe.style.height).toBe("500px"); }); + + test("calls onReady and onChange with the iframe's form state, and only for the live iframe", () => { + const onReady = vi.fn(); + const onChange = vi.fn(); + const listener = (event: string) => mocks.on.mock.calls.filter(([name]) => name === event).at(-1)?.[1]; + const view = render(); + + act(() => listener("payment-method:ready")?.({})); + act(() => listener("payment-method:change")?.({ mode: "new", empty: false, complete: true })); + expect(onReady).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ mode: "new", empty: false, complete: true }); + + const retiredChange = listener("payment-method:change"); + view.rerender(); + act(() => retiredChange?.({ mode: "existing", empty: false, complete: true })); + // The reload resets `complete`; the retired iframe's event is ignored. + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenLastCalledWith({ mode: "new", empty: false, complete: false }); + }); + + test("reports complete: false when a prop change reloads a complete form", () => { + const onChange = vi.fn(); + const listener = (event: string) => mocks.on.mock.calls.filter(([name]) => name === event).at(-1)?.[1]; + const view = render(); + act(() => listener("payment-method:change")?.({ mode: "existing", empty: false, complete: true })); + + view.rerender(); + + expect(onChange).toHaveBeenLastCalledWith({ mode: "existing", empty: false, complete: false }); + act(() => listener("payment-method:change")?.({ mode: "new", empty: true, complete: false })); + expect(onChange).toHaveBeenLastCalledWith({ mode: "new", empty: true, complete: false }); + expect(onChange).toHaveBeenCalledTimes(3); + }); + + test("does not report a reload when the form was not complete", () => { + const onChange = vi.fn(); + const listener = (event: string) => mocks.on.mock.calls.filter(([name]) => name === event).at(-1)?.[1]; + const view = render(); + act(() => listener("payment-method:change")?.({ mode: "new", empty: true, complete: false })); + + view.rerender(); + + expect(onChange).toHaveBeenCalledTimes(1); + }); + + test("reports complete: false when the iframe reloads in place", () => { + const onChange = vi.fn(); + const listener = (event: string) => mocks.on.mock.calls.filter(([name]) => name === event).at(-1)?.[1]; + render(); + const iframe = screen.getByRole("crossmint-payment-method-management.iframe"); + fireEvent.load(iframe); + act(() => listener("payment-method:change")?.({ mode: "new", empty: false, complete: true })); + + fireEvent.load(iframe); + + expect(onChange).toHaveBeenLastCalledWith({ mode: "new", empty: false, complete: false }); + expect(onChange).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/client/ui/react-ui/src/index.ts b/packages/client/ui/react-ui/src/index.ts index 65f11a133..6b5c3bf4f 100644 --- a/packages/client/ui/react-ui/src/index.ts +++ b/packages/client/ui/react-ui/src/index.ts @@ -25,6 +25,13 @@ export { type CrossmintPaymentMethodManagementRef, type CrossmintPaymentMethod, type PaymentMethodSubmitResult, + type PaymentMethodManagementChangeEvent, + type PaymentMethodSubmitInvalidCode, + type PaymentMethodSubmitFailedCode, + type PaymentMethodSubmitUnavailableCode, + PAYMENT_METHOD_SUBMIT_INVALID_CODES, + PAYMENT_METHOD_SUBMIT_FAILED_CODES, + PAYMENT_METHOD_SUBMIT_UNAVAILABLE_CODES, type CrossmintProtectedInputProps, type ProtectedInputAppearance, type ProtectedInputField,