From b649d63cebdd1e6a68644a2b4a35b467b5b7ac04 Mon Sep 17 00:00:00 2001 From: gciotola <30926550+gciotola@users.noreply.github.com> Date: Thu, 14 May 2026 17:46:56 +0200 Subject: [PATCH 1/3] feat: expose getPaymentInstrumentDetails helper --- packages/app-elements/src/main.ts | 1 + .../resources/ResourcePaymentMethod.test.tsx | 46 ++++++- .../ui/resources/ResourcePaymentMethod.tsx | 117 +++++++++++++----- .../ResourcePaymentMethod.stories.tsx | 35 +++++- 4 files changed, 163 insertions(+), 36 deletions(-) diff --git a/packages/app-elements/src/main.ts b/packages/app-elements/src/main.ts index 4d31930d6..01b1497cd 100644 --- a/packages/app-elements/src/main.ts +++ b/packages/app-elements/src/main.ts @@ -403,6 +403,7 @@ export { type ResourceOrderTimelineProps, } from "#ui/resources/ResourceOrderTimeline" export { + getPaymentInstrumentDetails, getPaymentMethodLogoSrc, ResourcePaymentMethod, type ResourcePaymentMethodProps, diff --git a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx index 2e15faf6e..371fb379b 100644 --- a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx +++ b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx @@ -1,6 +1,10 @@ import { fireEvent, render } from "@testing-library/react" -import { ResourcePaymentMethod } from "./ResourcePaymentMethod" import { + getPaymentInstrumentDetails, + ResourcePaymentMethod, +} from "./ResourcePaymentMethod" +import { + customerPaymentSource, orderWithoutPaymentSourceResponse, orderWithPaymentSourceResponse, } from "./ResourcePaymentMethod.mocks" @@ -53,4 +57,44 @@ describe("ResourcePaymentMethod", () => { expect(queryByText("resultCode:")).not.toBeInTheDocument() expect(queryByText("fraudResult:")).not.toBeInTheDocument() }) + + it("should render card details from a CustomerPaymentSource", () => { + const { getByText } = render( + , + ) + expect(getByText("Braintree")).toBeVisible() + expect(getByText("··0004")).toBeVisible() + expect(getByText(/10\/30/)).toBeVisible() + }) +}) + +describe("getPaymentInstrumentDetails", () => { + it("should return only paymentMethodName when no payment_instrument is present", () => { + const result = getPaymentInstrumentDetails( + orderWithoutPaymentSourceResponse, + ) + expect(result.paymentMethodName).toBe("Adyen Payment") + expect(result.cardType).toBeUndefined() + expect(result.issuerType).toBeUndefined() + expect(result.cardLastDigits).toBeUndefined() + expect(result.cardExpiry).toBeUndefined() + }) + + it("should return card details without expiry when expiry fields are missing", () => { + const result = getPaymentInstrumentDetails(orderWithPaymentSourceResponse) + expect(result.paymentMethodName).toBe("Adyen Payment") + expect(result.cardType).toBe("Amex") + expect(result.issuerType).toBe("credit card") + expect(result.cardLastDigits).toBe("4242") + expect(result.cardExpiry).toBeUndefined() + }) + + it("should return title-cased card type, formatted expiry, and name from CustomerPaymentSource", () => { + const result = getPaymentInstrumentDetails(customerPaymentSource) + expect(result.paymentMethodName).toBe("Braintree") + expect(result.cardType).toBe("Visa") + expect(result.issuerType).toBe("braintree") + expect(result.cardLastDigits).toBe("0004") + expect(result.cardExpiry).toBe("10/30") + }) }) diff --git a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx index 041f98879..f06e6f2f3 100644 --- a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx +++ b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx @@ -13,20 +13,22 @@ import { Button } from "#ui/atoms/Button" import { Spacer } from "#ui/atoms/Spacer" import { Text } from "#ui/atoms/Text" +type PaymentMethodResource = + | SetRequired< + SetNonNullable, "payment_method">, + "payment_method" + > + | SetRequired< + SetNonNullable, + "payment_source" + > + export interface ResourcePaymentMethodProps { /** * Any resource that has `payment_source` or `payment_method` properties is actually eligible. * But we are only interested in `Order` and `CustomerPaymentSource` resources. */ - resource: - | SetRequired< - SetNonNullable, "payment_method">, - "payment_method" - > - | SetRequired< - SetNonNullable, - "payment_source" - > + resource: PaymentMethodResource /** * When true and if `payment_source.payment_response` is present, enables the expandable content to show more details on the transaction. */ @@ -52,16 +54,13 @@ export const ResourcePaymentMethod: FC = ({ }) => { const [showMore, setShowMore] = useState(false) const { t } = useTranslation() - const paymentInstrument = paymentInstrumentType.safeParse( - resource.payment_source?.payment_instrument, - ) - - const paymentMethodName = - "payment_method" in resource - ? resource.payment_method?.name - : resource.type === "customer_payment_sources" - ? getPaymentMethodNameFromCustomerPaymentSource(resource) - : undefined + const { + paymentMethodName, + cardType, + issuerType, + cardLastDigits, + cardExpiry, + } = getPaymentInstrumentDetails(resource) const avatarSrc = getPaymentMethodLogoSrc( resource.payment_method?.payment_source_type ?? @@ -89,31 +88,28 @@ export const ResourcePaymentMethod: FC = ({ {paymentMethodName}
- {paymentInstrument.success ? ( + {issuerType != null ? (
{paymentMethodName} {" · "} - {paymentInstrument.data.card_type != null ? ( + {cardType != null ? ( - {paymentInstrument.data.card_type}{" "} - {paymentInstrument.data.issuer_type} - {paymentInstrument.data.card_last_digits != null && ( + {cardType} {issuerType} + {cardLastDigits != null && ( - ··{paymentInstrument.data.card_last_digits} + ··{cardLastDigits} + + )} + {cardExpiry != null && ( + + {`${t("common.card_expires")} `} + {cardExpiry} )} - {paymentInstrument.data.card_expiry_month != null && - paymentInstrument.data.card_expiry_year != null && ( - - {`${t("common.card_expires")} `} - {paymentInstrument.data.card_expiry_month}/ - {paymentInstrument.data.card_expiry_year.slice(2)} - - )} ) : ( - paymentInstrument.data.issuer_type + issuerType )}
@@ -258,3 +254,56 @@ function getPaymentMethodNameFromCustomerPaymentSource( return paymentMethodName } + +/** + * Extracts payment instrument details from an Order or CustomerPaymentSource resource. + * Returns individual parts, all of which may be `undefined` if not available. + */ +export function getPaymentInstrumentDetails(resource: PaymentMethodResource): { + paymentMethodName: string | undefined + cardType: string | undefined + issuerType: string | undefined + cardLastDigits: string | undefined + cardExpiry: string | undefined +} { + const rawName = + "payment_method" in resource + ? resource.payment_method?.name + : resource.type === "customer_payment_sources" + ? getPaymentMethodNameFromCustomerPaymentSource(resource) + : undefined + const paymentMethodName = rawName ?? undefined + + const paymentInstrument = paymentInstrumentType.safeParse( + resource.payment_source?.payment_instrument, + ) + + if (!paymentInstrument.success) { + return { + paymentMethodName, + cardType: undefined, + issuerType: undefined, + cardLastDigits: undefined, + cardExpiry: undefined, + } + } + + const { + card_type, + issuer_type, + card_last_digits, + card_expiry_month, + card_expiry_year, + } = paymentInstrument.data + + return { + paymentMethodName, + cardType: card_type, + issuerType: issuer_type, + cardLastDigits: card_last_digits, + cardExpiry: + card_expiry_month != null && card_expiry_year != null + ? `${card_expiry_month}/${card_expiry_year.slice(2)}` + : undefined, + } +} diff --git a/packages/docs/src/stories/resources/ResourcePaymentMethod.stories.tsx b/packages/docs/src/stories/resources/ResourcePaymentMethod.stories.tsx index bed87adc2..93aabdb45 100644 --- a/packages/docs/src/stories/resources/ResourcePaymentMethod.stories.tsx +++ b/packages/docs/src/stories/resources/ResourcePaymentMethod.stories.tsx @@ -1,6 +1,9 @@ import type { Meta, StoryFn } from "@storybook/react-vite" import { Icon } from "#ui/atoms/Icon" -import { ResourcePaymentMethod } from "#ui/resources/ResourcePaymentMethod" +import { + getPaymentInstrumentDetails, + ResourcePaymentMethod, +} from "#ui/resources/ResourcePaymentMethod" import { customerPaymentSource, orderWithoutPaymentSourceResponse, @@ -77,3 +80,33 @@ WithActionButton.args = { ), } + +/** + * `getPaymentInstrumentDetails` is a standalone helper that extracts payment data + * from an `Order` or `CustomerPaymentSource` resource as plain strings, without + * rendering any UI. Useful when you need those values independently. + */ +export const HelperGetPaymentInstrumentDetails: StoryFn = () => { + const examples = [ + { label: "Order", resource: orderWithPaymentSourceResponse }, + { label: "CustomerPaymentSource", resource: customerPaymentSource }, + ] + + return ( +
+ {examples.map(({ label, resource }) => { + const details = getPaymentInstrumentDetails(resource) + return ( +
+

+ {label} +

+
{JSON.stringify(details, null, 2)}
+
+ ) + })} +
+ ) +} +HelperGetPaymentInstrumentDetails.storyName = + "Helper: getPaymentInstrumentDetails" From 074151b580ecf11f12ec51cbded70d310b0f1c0d Mon Sep 17 00:00:00 2001 From: gciotola <30926550+gciotola@users.noreply.github.com> Date: Fri, 15 May 2026 10:53:21 +0200 Subject: [PATCH 2/3] fix: always format card last digits --- .../src/ui/resources/ResourcePaymentMethod.test.tsx | 4 ++-- .../app-elements/src/ui/resources/ResourcePaymentMethod.tsx | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx index 371fb379b..e7d5aa066 100644 --- a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx +++ b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.test.tsx @@ -85,7 +85,7 @@ describe("getPaymentInstrumentDetails", () => { expect(result.paymentMethodName).toBe("Adyen Payment") expect(result.cardType).toBe("Amex") expect(result.issuerType).toBe("credit card") - expect(result.cardLastDigits).toBe("4242") + expect(result.cardLastDigits).toBe("··4242") expect(result.cardExpiry).toBeUndefined() }) @@ -94,7 +94,7 @@ describe("getPaymentInstrumentDetails", () => { expect(result.paymentMethodName).toBe("Braintree") expect(result.cardType).toBe("Visa") expect(result.issuerType).toBe("braintree") - expect(result.cardLastDigits).toBe("0004") + expect(result.cardLastDigits).toBe("··0004") expect(result.cardExpiry).toBe("10/30") }) }) diff --git a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx index f06e6f2f3..a800992c3 100644 --- a/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx +++ b/packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx @@ -98,7 +98,7 @@ export const ResourcePaymentMethod: FC = ({ {cardType} {issuerType} {cardLastDigits != null && ( - ··{cardLastDigits} + {cardLastDigits} )} {cardExpiry != null && ( @@ -300,7 +300,8 @@ export function getPaymentInstrumentDetails(resource: PaymentMethodResource): { paymentMethodName, cardType: card_type, issuerType: issuer_type, - cardLastDigits: card_last_digits, + cardLastDigits: + card_last_digits != null ? `··${card_last_digits}` : undefined, cardExpiry: card_expiry_month != null && card_expiry_year != null ? `${card_expiry_month}/${card_expiry_year.slice(2)}` From c947323eafdcd69a4fb34351fddefa480909b7f4 Mon Sep 17 00:00:00 2001 From: gciotola <30926550+gciotola@users.noreply.github.com> Date: Fri, 15 May 2026 11:38:49 +0200 Subject: [PATCH 3/3] fix: add optional cancel button label to ConfirmDialog --- packages/app-elements/src/hooks/useConfirmDialog.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app-elements/src/hooks/useConfirmDialog.tsx b/packages/app-elements/src/hooks/useConfirmDialog.tsx index b00c78ce5..ed02c2d68 100644 --- a/packages/app-elements/src/hooks/useConfirmDialog.tsx +++ b/packages/app-elements/src/hooks/useConfirmDialog.tsx @@ -28,6 +28,8 @@ interface ConfirmDialogProps { description?: React.ReactNode /** Configuration for the confirm (primary action) button */ confirm: ConfirmDialogConfirmProps + /** Optional label for the cancel button - Default "Cancel" */ + cancelLabel?: string /** * Message shown in the error toast when `confirm.onClick` rejects and overrides the API errors. */ @@ -53,6 +55,7 @@ const ConfirmDialog: FC = ({ title, description, confirm, + cancelLabel = "Cancel", errorMessage, successMessage, successVariant = "default", @@ -113,7 +116,7 @@ const ConfirmDialog: FC = ({ disabled={isPending} fullWidth > - Cancel + {cancelLabel}