Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/olive-buttons-hide.md
Original file line number Diff line number Diff line change
@@ -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`.
30 changes: 14 additions & 16 deletions apps/payments/nextjs/app/payment-method-management/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useCrossmintAuth,
CrossmintPaymentMethodManagement,
type CrossmintPaymentMethodManagementRef,
type PaymentMethodManagementChangeEvent,
type PaymentMethodSubmitResult,
} from "@crossmint/client-sdk-react-ui";
import { useRef, useState } from "react";
Expand All @@ -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<PaymentMethodManagementChangeEvent | null>(null);
const [collectBillingAddress, setCollectBillingAddress] = useState(false);
const [billingLine2, setBillingLine2] = useState<"optional" | "required" | "hidden">("optional");
const [result, setResult] = useState<PaymentMethodSubmitResult | null>(null);
Expand All @@ -44,10 +46,6 @@ function PaymentMethodManagementWrapper() {

return (
<div style={{ maxWidth: 480 }}>
<label style={{ display: "block", marginBottom: 16 }}>
<input type="checkbox" checked={ownButton} onChange={(e) => setOwnButton(e.target.checked)} /> Use my
own submit button
</label>
<label style={{ display: "block", marginBottom: 16 }}>
<input
type="checkbox"
Expand All @@ -73,20 +71,20 @@ function PaymentMethodManagementWrapper() {
ref={pmm}
jwt={jwt}
allowedModes={["new", "existing"]}
hideSubmitButton={ownButton}
fields={collectBillingAddress ? { billingDetails: { address: { line2: billingLine2 } } } : undefined}
onPaymentMethodSelected={(paymentMethod) => console.log("payment-method:selected", paymentMethod)}
onReady={() => setReady(true)}
onChange={setFormState}
/>
{ownButton && (
<button
type="button"
style={{ marginTop: 16, padding: "12px 20px" }}
onClick={submitPaymentMethod}
disabled={submitting}
>
{submitting ? "Saving…" : "My own submit button"}
</button>
)}
<button
type="button"
style={{ marginTop: 16, padding: "12px 20px" }}
onClick={submitPaymentMethod}
disabled={!ready || formState?.complete !== true || submitting}
>
{submitting ? "Saving…" : "My own submit button"}
</button>
<pre style={{ marginTop: 16 }}>{JSON.stringify({ ready, formState }, null, 2)}</pre>
{result != null && <pre style={{ marginTop: 16 }}>{JSON.stringify(result, null, 2)}</pre>}
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,8 +83,51 @@ export interface CrossmintPaymentMethodManagementProps {
*/
defaultValues?: PaymentMethodManagementDefaultValues | null;
onPaymentMethodSelected?: (paymentMethod: CrossmintPaymentMethod) => void | Promise<void>;
/**
* 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.
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"];
Expand All @@ -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);
Expand All @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -1,23 +1,43 @@
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
.object({ status: z.string(), message: z.string().optional() })
.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.",
})),
]);
Expand All @@ -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<PaymentMethodManagementChangeEvent>,
"payment-method:result": z.object({
requestId: z.string().min(1),
result: paymentMethodSubmitResultSchema,
Expand Down
Loading
Loading