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
5 changes: 4 additions & 1 deletion packages/app-elements/src/hooks/useConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -53,6 +55,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
title,
description,
confirm,
cancelLabel = "Cancel",
errorMessage,
successMessage,
successVariant = "default",
Expand Down Expand Up @@ -113,7 +116,7 @@ const ConfirmDialog: FC<ConfirmDialogProps> = ({
disabled={isPending}
fullWidth
>
Cancel
{cancelLabel}
</Button>
</Modal.Footer>
</Modal>
Expand Down
1 change: 1 addition & 0 deletions packages/app-elements/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,7 @@ export {
type ResourceOrderTimelineProps,
} from "#ui/resources/ResourceOrderTimeline"
export {
getPaymentInstrumentDetails,
getPaymentMethodLogoSrc,
ResourcePaymentMethod,
type ResourcePaymentMethodProps,
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(
<ResourcePaymentMethod resource={customerPaymentSource} />,
)
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")
})
})
118 changes: 84 additions & 34 deletions packages/app-elements/src/ui/resources/ResourcePaymentMethod.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Partial<Order>, "payment_method">,
"payment_method"
>
| SetRequired<
SetNonNullable<CustomerPaymentSource, "payment_source">,
"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<Partial<Order>, "payment_method">,
"payment_method"
>
| SetRequired<
SetNonNullable<CustomerPaymentSource, "payment_source">,
"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.
*/
Expand All @@ -52,16 +54,13 @@ export const ResourcePaymentMethod: FC<ResourcePaymentMethodProps> = ({
}) => {
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 ??
Expand Down Expand Up @@ -89,31 +88,28 @@ export const ResourcePaymentMethod: FC<ResourcePaymentMethodProps> = ({
<img src={avatarSrc} alt={paymentMethodName} className="h-8" />
<div className="flex gap-4 items-center justify-between w-full">
<div className="flex flex-col gap-0">
{paymentInstrument.success ? (
{issuerType != null ? (
<div>
<Text weight="semibold">{paymentMethodName}</Text>
<Text>{" · "}</Text>
<Text weight="medium" variant="info">
{paymentInstrument.data.card_type != null ? (
{cardType != null ? (
<span>
{paymentInstrument.data.card_type}{" "}
{paymentInstrument.data.issuer_type}
{paymentInstrument.data.card_last_digits != null && (
{cardType} {issuerType}
{cardLastDigits != null && (
<Spacer left="2" style={{ display: "inline-block" }}>
··{paymentInstrument.data.card_last_digits}
{cardLastDigits}
</Spacer>
)}
{cardExpiry != null && (
<Spacer left="1" style={{ display: "inline-block" }}>
{`${t("common.card_expires")} `}
{cardExpiry}
</Spacer>
)}
{paymentInstrument.data.card_expiry_month != null &&
paymentInstrument.data.card_expiry_year != null && (
<Spacer left="1" style={{ display: "inline-block" }}>
{`${t("common.card_expires")} `}
{paymentInstrument.data.card_expiry_month}/
{paymentInstrument.data.card_expiry_year.slice(2)}
</Spacer>
)}
</span>
) : (
paymentInstrument.data.issuer_type
issuerType
)}
</Text>
</div>
Expand Down Expand Up @@ -258,3 +254,57 @@ 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 != null ? `··${card_last_digits}` : undefined,
cardExpiry:
card_expiry_month != null && card_expiry_year != null
? `${card_expiry_month}/${card_expiry_year.slice(2)}`
: undefined,
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -77,3 +80,33 @@ WithActionButton.args = {
</button>
),
}

/**
* `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 (
<div style={{ display: "flex", flexDirection: "column", gap: "2rem" }}>
{examples.map(({ label, resource }) => {
const details = getPaymentInstrumentDetails(resource)
return (
<div key={label}>
<p style={{ fontWeight: "bold", marginBottom: "0.5rem" }}>
{label}
</p>
<pre style={{ margin: 0 }}>{JSON.stringify(details, null, 2)}</pre>
</div>
)
})}
</div>
)
}
HelperGetPaymentInstrumentDetails.storyName =
"Helper: getPaymentInstrumentDetails"
Loading