Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/pmm-mode-and-new-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@crossmint/client-sdk-base": minor
"@crossmint/client-sdk-react-ui": minor
---

`CrossmintPaymentMethodManagement`: add `mode` (`"select-or-add" | "add-only"`) and `allowedPaymentMethodTypes` props. Make `jwt` optional — when omitted, the component tokenizes the card without persisting a saved payment method and emits a `card-token` selection event.

Introduces `CrossmintNewCard`, a tokenize-only wrapper around `CrossmintPaymentMethodManagement` for use cases that don't need a signed-in user (e.g., embedded checkout).

The `onPaymentMethodSelected` callback now receives a discriminated union: `{ type: "card", paymentMethod }` when a saved method is selected/created, or `{ type: "card-token", cardToken }` when tokenizing without JWT.
36 changes: 36 additions & 0 deletions apps/payments/nextjs/app/new-card/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use client";

import { CrossmintProvider, CrossmintNewCard } from "@crossmint/client-sdk-react-ui";

export default function NewCardPage() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "start",
padding: "20px",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "start",
width: "100%",
maxWidth: "450px",
}}
>
<CrossmintProvider apiKey="ck_development_5ZUNkuhjP8aYZEgUTDfWToqFpo5zakEqte1db4pHZgPAVKZ9JuTQKmeRbn1gv7zYCoZrRNYy4CnM7A3AMHQxFKA2BsSVeZbKEvXXY7126Th68mXhTg6oxHJpC2kuw9Q1HasVLX9LM67FoYSTRtTUUEzP93GUSEmeG5CZG7Lbop4oAQ7bmZUKTGmqN9L9wxP27CH13WaTBsrqxUJkojbKUXEd">
<CrossmintNewCard
onCardTokenized={(cardToken) => {
console.log("card tokenized", cardToken);
}}
/>
</CrossmintProvider>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { EmbeddedCheckoutV3Appearance } from "../embed";

export type PaymentMethodManagementMode = "select-or-add" | "add-only";
export type PaymentMethodManagementAllowedType = "card";

export interface CrossmintPaymentMethodManagementProps {
jwt: string;
appearance?: PaymentMethodManagementAppearance;
jwt?: string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

for now lets just keep jwt as required, we will tackl non-authenticated cases later

mode?: PaymentMethodManagementMode;
allowedPaymentMethodTypes?: PaymentMethodManagementAllowedType[];
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

can u make this an object instead
i think its like:

allowedPaymentMethodTypes?: Partial<Record<PaymentMethodManagementAllowedType, boolean>>

appearance?: EmbeddedCheckoutV3Appearance;
onPaymentMethodSelected?: (paymentMethod: CrossmintPaymentMethod) => void | Promise<void>;
}

export type CrossmintPaymentMethod = {
export type CrossmintCardPaymentMethod = {
type: "card";
paymentMethodId: string;
card: {
Expand All @@ -29,7 +34,13 @@ export type CrossmintPaymentMethod = {
};
};

export type PaymentMethodManagementAppearance = {
fonts?: EmbeddedCheckoutV3Appearance["fonts"];
variables?: EmbeddedCheckoutV3Appearance["variables"];
export type CrossmintCardToken = {
id: string;
billing?: {
name?: string;
};
};

export type CrossmintPaymentMethod =
| { type: "card"; paymentMethod: CrossmintCardPaymentMethod }
| { type: "card-token"; cardToken: CrossmintCardToken };
Comment on lines +37 to +46
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

since jwt required, remove card-token case

Original file line number Diff line number Diff line change
@@ -1,9 +1,47 @@
import { z } from "zod";

const cardTokenSelectedSchema = z.object({
type: z.literal("card-token"),
cardToken: z.object({
id: z.string(),
billing: z
.object({
name: z.string().optional(),
})
.optional(),
}),
});

const cardPaymentMethodSelectedSchema = z.object({
type: z.literal("card"),
paymentMethod: z.object({
type: z.literal("card"),
paymentMethodId: z.string(),
card: z.object({
source: z.object({
type: z.literal("basis-theory-token"),
id: z.string(),
}),
brand: z.string(),
last4: z.string(),
expiration: z.object({
month: z.string(),
year: z.string(),
}),
}),
default: z.boolean().optional(),
display: z
.object({
imageUrl: z.string().optional(),
})
.optional(),
}),
});
Comment on lines +15 to +39
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Zod schema inferred type won't satisfy CrossmintCardPaymentMethod

cardPaymentMethodSelectedSchema only declares type and paymentMethodId inside paymentMethod, relying on .passthrough() to preserve the remaining runtime fields. However, .passthrough() does not add the undeclared fields to Zod's inferred TypeScript output type — it only prevents them from being stripped at runtime. The inferred type for paymentMethod is therefore { type: "card"; paymentMethodId: string }, which is missing the required card: { source, brand, last4, expiration } field of CrossmintCardPaymentMethod.

This causes a structural type mismatch: when the validated event data is passed to props.onPaymentMethodSelected?.(data) in CrossmintPaymentMethodManagementIFrame.tsx, TypeScript sees the Zod-inferred union rather than CrossmintPaymentMethod, and they are not mutually assignable. This will surface as a compile error on the unchanged IFrame component.

To fix, either extend the schema to include all required CrossmintCardPaymentMethod fields, or cast the validated data to CrossmintPaymentMethod at the call-site in the IFrame component.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/client/base/src/types/payment-method-management/events/incoming.ts
Line: 15-23

Comment:
**Zod schema inferred type won't satisfy `CrossmintCardPaymentMethod`**

`cardPaymentMethodSelectedSchema` only declares `type` and `paymentMethodId` inside `paymentMethod`, relying on `.passthrough()` to preserve the remaining runtime fields. However, `.passthrough()` does not add the undeclared fields to Zod's inferred TypeScript output type — it only prevents them from being stripped at runtime. The inferred type for `paymentMethod` is therefore `{ type: "card"; paymentMethodId: string }`, which is missing the required `card: { source, brand, last4, expiration }` field of `CrossmintCardPaymentMethod`.

This causes a structural type mismatch: when the validated event data is passed to `props.onPaymentMethodSelected?.(data)` in `CrossmintPaymentMethodManagementIFrame.tsx`, TypeScript sees the Zod-inferred union rather than `CrossmintPaymentMethod`, and they are not mutually assignable. This will surface as a compile error on the unchanged IFrame component.

To fix, either extend the schema to include all required `CrossmintCardPaymentMethod` fields, or cast the validated data to `CrossmintPaymentMethod` at the call-site in the IFrame component.

How can I resolve this? If you propose a fix, please make it concise.


export const paymentMethodManagementIncomingEvents = {
"ui:height.changed": z.object({
height: z.number(),
}),
"payment-method:selected": z.any(),
"payment-method:selected": z.discriminatedUnion("type", [cardPaymentMethodSelectedSchema, cardTokenSelectedSchema]),
};
export type PaymentMethodManagementIncomingEventMap = typeof paymentMethodManagementIncomingEvents;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {
CrossmintCardPaymentMethod,
CrossmintCardToken,
EmbeddedCheckoutV3Appearance,
} from "@crossmint/client-sdk-base";
import { CrossmintPaymentMethodManagement } from "./CrossmintPaymentMethodManagement";

export interface CrossmintNewCardProps {
jwt?: string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

make required

appearance?: EmbeddedCheckoutV3Appearance;
onCardTokenized?: (cardToken: CrossmintCardToken) => void | Promise<void>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

same, remove

onPaymentMethodAdded?: (paymentMethod: CrossmintCardPaymentMethod) => void | Promise<void>;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

onCardAdded

}

export function CrossmintNewCard(props: CrossmintNewCardProps) {
return (
<CrossmintPaymentMethodManagement
jwt={props.jwt}
mode="add-only"
allowedPaymentMethodTypes={["card"]}
appearance={props.appearance}
onPaymentMethodSelected={(result) => {
if (result.type === "card-token") {
return props.onCardTokenized?.(result.cardToken);
}
Comment on lines +23 to +25
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

remove

return props.onPaymentMethodAdded?.(result.paymentMethod);
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./CrossmintPaymentMethodManagement";
export * from "./CrossmintNewCard";
Loading