From 25ec3620ed91d8d39c651808d48a9824a7ca6dec Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Sun, 30 Mar 2025 10:28:21 -0700 Subject: [PATCH 01/10] add PaymentSources and AddPaymentSource components --- .../src/core/modules/commerce/Commerce.ts | 23 +- .../core/resources/CommercePaymentSource.ts | 30 ++ .../components/Checkout/CheckoutComplete.tsx | 1 + .../ui/components/Checkout/CheckoutForm.tsx | 184 +------------ .../ui/components/Checkout/CheckoutPage.tsx | 23 -- .../components/Commerce/AddPaymentSource.tsx | 258 ++++++++++++++++++ .../ui/components/Commerce/PaymentSources.tsx | 139 ++++++++++ .../src/ui/components/Commerce/index.ts | 2 + .../ui/components/UserProfile/BillingPage.tsx | 5 +- packages/localizations/src/en-US.ts | 9 + packages/types/src/appearance.ts | 5 + packages/types/src/clerk.ts | 6 + packages/types/src/commerce.ts | 24 +- packages/types/src/elementIds.ts | 3 +- packages/types/src/json.ts | 8 + packages/types/src/localization.ts | 9 + 16 files changed, 532 insertions(+), 197 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/Commerce/AddPaymentSource.tsx create mode 100644 packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx create mode 100644 packages/clerk-js/src/ui/components/Commerce/index.ts diff --git a/packages/clerk-js/src/core/modules/commerce/Commerce.ts b/packages/clerk-js/src/core/modules/commerce/Commerce.ts index 5a0c5c8afd1..02d22c14984 100644 --- a/packages/clerk-js/src/core/modules/commerce/Commerce.ts +++ b/packages/clerk-js/src/core/modules/commerce/Commerce.ts @@ -1,12 +1,19 @@ import type { __experimental_AddPaymentSourceParams, __experimental_CommerceBillingNamespace, + __experimental_CommerceInitializedPaymentSourceJSON, __experimental_CommerceNamespace, __experimental_CommercePaymentSourceJSON, + __experimental_GetPaymentSourcesParams, + __experimental_InitializePaymentSourceParams, ClerkPaginatedResponse, } from '@clerk/types'; -import { __experimental_CommercePaymentSource, BaseResource } from '../../resources/internal'; +import { + __experimental_CommerceInitializedPaymentSource, + __experimental_CommercePaymentSource, + BaseResource, +} from '../../resources/internal'; import { __experimental_CommerceBilling } from './CommerceBilling'; export class __experimental_Commerce implements __experimental_CommerceNamespace { @@ -19,6 +26,17 @@ export class __experimental_Commerce implements __experimental_CommerceNamespace return __experimental_Commerce._billing; } + initializePaymentSource = async (params: __experimental_InitializePaymentSourceParams) => { + const json = ( + await BaseResource._fetch({ + path: `/me/commerce/payment_sources/initialize`, + method: 'POST', + body: params as any, + }) + )?.response as unknown as __experimental_CommerceInitializedPaymentSourceJSON; + return new __experimental_CommerceInitializedPaymentSource(json); + }; + addPaymentSource = async (params: __experimental_AddPaymentSourceParams) => { const json = ( await BaseResource._fetch({ @@ -30,10 +48,11 @@ export class __experimental_Commerce implements __experimental_CommerceNamespace return new __experimental_CommercePaymentSource(json); }; - getPaymentSources = async () => { + getPaymentSources = async (params: __experimental_GetPaymentSourcesParams) => { return await BaseResource._fetch({ path: `/me/commerce/payment_sources`, method: 'GET', + search: { orgId: params.orgId || '' }, }).then(res => { const { data: paymentSources, total_count } = res as unknown as ClerkPaginatedResponse<__experimental_CommercePaymentSourceJSON>; diff --git a/packages/clerk-js/src/core/resources/CommercePaymentSource.ts b/packages/clerk-js/src/core/resources/CommercePaymentSource.ts index b7faf86881c..5fc90621a96 100644 --- a/packages/clerk-js/src/core/resources/CommercePaymentSource.ts +++ b/packages/clerk-js/src/core/resources/CommercePaymentSource.ts @@ -1,6 +1,9 @@ import type { + __experimental_CommerceInitializedPaymentSourceJSON, + __experimental_CommerceInitializedPaymentSourceResource, __experimental_CommercePaymentSourceJSON, __experimental_CommercePaymentSourceResource, + __experimental_CommercePaymentSourceStatus, } from '@clerk/types'; import { BaseResource } from './internal'; @@ -13,6 +16,8 @@ export class __experimental_CommercePaymentSource last4!: string; paymentMethod!: string; cardType!: string; + isDefault!: boolean; + status!: __experimental_CommercePaymentSourceStatus; constructor(data: __experimental_CommercePaymentSourceJSON) { super(); @@ -28,6 +33,31 @@ export class __experimental_CommercePaymentSource this.last4 = data.last4; this.paymentMethod = data.payment_method; this.cardType = data.card_type; + this.isDefault = false; + this.status = data.status; + return this; + } +} + +export class __experimental_CommerceInitializedPaymentSource + extends BaseResource + implements __experimental_CommerceInitializedPaymentSourceResource +{ + externalClientSecret!: string; + externalGatewayId!: string; + + constructor(data: __experimental_CommerceInitializedPaymentSourceJSON) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: __experimental_CommerceInitializedPaymentSourceJSON | null): this { + if (!data) { + return this; + } + + this.externalClientSecret = data.external_client_secret; + this.externalGatewayId = data.external_gateway_id; return this; } diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index dea80762f82..057b5e5921d 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -77,6 +77,7 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer as='h2' textVariant='h2' > + {/* TODO(@COMMERCE): needs localization */} Payment was successful! void; }) => { const { plan, planPeriod, totals } = checkout; - const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; - const elementsAppearance: StripeAppearance = { - variables: { - colorPrimary: normalizeColorString(colors.$primary500), - colorBackground: normalizeColorString(colors.$colorInputBackground), - colorText: normalizeColorString(colors.$colorText), - colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), - colorSuccess: normalizeColorString(colors.$success500), - colorDanger: normalizeColorString(colors.$danger500), - colorWarning: normalizeColorString(colors.$warning500), - fontWeightNormal: fontWeights.$normal.toString(), - fontWeightMedium: fontWeights.$medium.toString(), - fontWeightBold: fontWeights.$bold.toString(), - fontSizeXl: fontSizes.$xl, - fontSizeLg: fontSizes.$lg, - fontSizeSm: fontSizes.$md, - fontSizeXs: fontSizes.$sm, - borderRadius: radii.$md, - spacingUnit: space.$1, - }, - }; + return ( - {stripe && ( - - - - )} + ); }; @@ -117,8 +87,6 @@ const CheckoutFormElements = ({ checkout: __experimental_CommerceCheckoutResource; onCheckoutComplete: (checkout: __experimental_CommerceCheckoutResource) => void; }) => { - const stripe = useStripe(); - const elements = useElements(); const { __experimental_commerce } = useClerk(); const [openAccountFundsDropDown, setOpenAccountFundsDropDown] = useState(true); const [openAddNewSourceDropDown, setOpenAddNewSourceDropDown] = useState(true); @@ -160,39 +128,8 @@ const CheckoutFormElements = ({ } }; - const onStripeSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!stripe || !elements) { - return; - } - setIsSubmitting(true); - setSubmitError(undefined); - - try { - const { setupIntent, error } = await stripe.confirmSetup({ - elements, - confirmParams: { - return_url: '', // TODO(@COMMERCE): need to figure this out - }, - redirect: 'if_required', - }); - if (error) { - return; - } - - const paymentSource = await __experimental_commerce.addPaymentSource({ - gateway: 'stripe', - paymentMethod: 'card', - paymentToken: setupIntent.payment_method as string, - }); - - await confirmCheckout({ paymentSourceId: paymentSource.id }); - } catch (error) { - console.log(error); - handleError(error, [], setSubmitError); - } finally { - setIsSubmitting(false); - } + const onAddPaymentSourceSuccess = async (paymentSource: __experimental_CommercePaymentSourceResource) => { + await confirmCheckout({ paymentSourceId: paymentSource.id }); }; return ( @@ -241,11 +178,11 @@ const CheckoutFormElements = ({ {/* TODO(@Commerce): needs localization */} - @@ -348,94 +285,3 @@ const PaymentSourceMethods = ({ ); }; - -const StripePaymentMethods = ({ - totalDueNow, - onStripeSubmit, - onExpand, - isSubmitting, -}: { - totalDueNow: __experimental_CommerceMoney; - onStripeSubmit: React.FormEventHandler; - onExpand: () => void; - isSubmitting: boolean; -}) => { - const [collapsed, setCollapsed] = useState(true); - - useEffect(() => { - if (!collapsed) { - onExpand(); - } - }, [collapsed, onExpand]); - - return ( -
({ - display: 'flex', - flexDirection: 'column', - rowGap: t.space.$3, - })} - > - {collapsed ? ( - <> - - - - - ) : ( - <> - - - - )} - - ); -}; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 8920920f09f..a24982f2e80 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,9 +1,5 @@ import type { __experimental_CheckoutProps, __experimental_CommerceCheckoutResource } from '@clerk/types'; -import type { Stripe } from '@stripe/stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; -import { useEffect, useRef, useState } from 'react'; -import { useEnvironment } from '../../contexts'; import { Alert, Spinner } from '../../customizables'; import { useCheckout } from '../../hooks'; import { CheckoutComplete } from './CheckoutComplete'; @@ -11,9 +7,6 @@ import { CheckoutForm } from './CheckoutForm'; export const CheckoutPage = (props: __experimental_CheckoutProps) => { const { planId, planPeriod, orgId, onSubscriptionComplete } = props; - const stripePromiseRef = useRef | null>(null); - const [stripe, setStripe] = useState(null); - const { __experimental_commerceSettings } = useEnvironment(); const { checkout, updateCheckout, isLoading } = useCheckout({ planId, @@ -21,21 +14,6 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => { orgId, }); - useEffect(() => { - if ( - !stripePromiseRef.current && - checkout?.externalGatewayId && - __experimental_commerceSettings.stripePublishableKey - ) { - stripePromiseRef.current = loadStripe(__experimental_commerceSettings.stripePublishableKey, { - stripeAccount: checkout.externalGatewayId, - }); - void stripePromiseRef.current.then(stripeInstance => { - setStripe(stripeInstance); - }); - } - }, [checkout?.externalGatewayId, __experimental_commerceSettings]); - const onCheckoutComplete = (newCheckout: __experimental_CommerceCheckoutResource) => { updateCheckout(newCheckout); onSubscriptionComplete?.(); @@ -73,7 +51,6 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => { return ( diff --git a/packages/clerk-js/src/ui/components/Commerce/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/Commerce/AddPaymentSource.tsx new file mode 100644 index 00000000000..b921418e4b0 --- /dev/null +++ b/packages/clerk-js/src/ui/components/Commerce/AddPaymentSource.tsx @@ -0,0 +1,258 @@ +import { useClerk } from '@clerk/shared/react'; +import type { + __experimental_CommerceCheckoutResource, + __experimental_CommercePaymentSourceResource, + ClerkAPIError, + ClerkRuntimeError, +} from '@clerk/types'; +import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; +import type { Appearance as StripeAppearance, Stripe } from '@stripe/stripe-js'; +import { loadStripe } from '@stripe/stripe-js'; +import { useEffect, useRef, useState } from 'react'; + +import { useEnvironment } from '../../contexts'; +import { Button, descriptors, Flex, Form, useAppearance } from '../../customizables'; +import { Alert } from '../../elements'; +import { useFetch } from '../../hooks/useFetch'; +import { animations } from '../../styledSystem'; +import { handleError, normalizeColorString } from '../../utils'; + +type AddPaymentSourceProps = { + onSuccess: (paymentSource: __experimental_CommercePaymentSourceResource) => Promise; + checkout?: __experimental_CommerceCheckoutResource; + submitButtonText?: string; + cancelAction?: () => void; + cancelButtonText?: string; + onExpand?: () => void; +}; + +type AddPaymentSourceFormProps = Omit; + +export const AddPaymentSource = (props: AddPaymentSourceProps) => { + const { checkout, submitButtonText, onSuccess, onExpand, cancelAction, cancelButtonText } = props; + const { __experimental_commerce } = useClerk(); + const { __experimental_commerceSettings } = useEnvironment(); + + const stripePromiseRef = useRef | null>(null); + const [stripe, setStripe] = useState(null); + + const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; + const elementsAppearance: StripeAppearance = { + variables: { + colorPrimary: normalizeColorString(colors.$primary500), + colorBackground: normalizeColorString(colors.$colorInputBackground), + colorText: normalizeColorString(colors.$colorText), + colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), + colorSuccess: normalizeColorString(colors.$success500), + colorDanger: normalizeColorString(colors.$danger500), + colorWarning: normalizeColorString(colors.$warning500), + fontWeightNormal: fontWeights.$normal.toString(), + fontWeightMedium: fontWeights.$medium.toString(), + fontWeightBold: fontWeights.$bold.toString(), + fontSizeXl: fontSizes.$xl, + fontSizeLg: fontSizes.$lg, + fontSizeSm: fontSizes.$md, + fontSizeXs: fontSizes.$sm, + borderRadius: radii.$md, + spacingUnit: space.$1, + }, + }; + + // if we have a checkout, we can use the checkout's client secret and gateway id + // otherwise, we need to initialize a new payment source + const { data: initializedPaymentSource } = useFetch( + !checkout ? __experimental_commerce.initializePaymentSource : undefined, + { + gateway: 'stripe', + }, + ); + + const externalGatewayId = checkout?.externalGatewayId ?? initializedPaymentSource?.externalGatewayId; + const externalClientSecret = checkout?.externalClientSecret ?? initializedPaymentSource?.externalClientSecret; + + useEffect(() => { + if (!stripePromiseRef.current && externalGatewayId && __experimental_commerceSettings.stripePublishableKey) { + stripePromiseRef.current = loadStripe(__experimental_commerceSettings.stripePublishableKey, { + stripeAccount: externalGatewayId, + }); + void stripePromiseRef.current.then(stripeInstance => { + setStripe(stripeInstance); + }); + } + }, [externalGatewayId, __experimental_commerceSettings]); + + if (!stripe || !externalClientSecret) return null; + + return ( + + + + ); +}; + +const AddPaymentSourceForm = ({ + submitButtonText, + onSuccess, + onExpand, + cancelAction, + cancelButtonText, +}: AddPaymentSourceFormProps) => { + const { __experimental_commerce } = useClerk(); + const stripe = useStripe(); + const elements = useElements(); + const [collapsed, setCollapsed] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(); + + useEffect(() => { + if (!collapsed) { + onExpand?.(); + } + }, [collapsed, onExpand]); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!stripe || !elements) { + return; + } + setIsSubmitting(true); + setSubmitError(undefined); + + try { + const { setupIntent, error } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: '', // TODO(@COMMERCE): need to figure this out + }, + redirect: 'if_required', + }); + if (error) { + return handleError(new Error(error.message), [], setSubmitError); + } + + const paymentSource = await __experimental_commerce.addPaymentSource({ + gateway: 'stripe', + paymentMethod: 'card', // TODO(@Commerce): need to use the real value? + paymentToken: setupIntent.payment_method as string, + }); + + await onSuccess(paymentSource); + } catch (error) { + handleError(error, [], setSubmitError); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
({ + display: 'flex', + flexDirection: 'column', + rowGap: t.space.$3, + })} + > + {collapsed ? ( + <> + + + + {cancelAction ? ( + + ) : null} + + ) : ( + <> + + {submitError && ( + ({ + animation: `${animations.textInBig} ${t.transitionDuration.$slow}`, + })} + > + {typeof submitError === 'string' ? submitError : submitError.message} + + )} + + {cancelAction && !isSubmitting ? ( + + ) : null} + + + + )} + + ); +}; diff --git a/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx b/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx new file mode 100644 index 00000000000..9d6b149988a --- /dev/null +++ b/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx @@ -0,0 +1,139 @@ +import { useClerk } from '@clerk/shared/react'; +import type { __experimental_CommercePaymentSourceResource, __experimental_PaymentSourcesProps } from '@clerk/types'; + +import { Badge, descriptors, Flex, Icon, localizationKeys, Spinner, Text } from '../../customizables'; +import { ProfileSection, ThreeDotsMenu, useCardState } from '../../elements'; +import { Action } from '../../elements/Action'; +import { useActionContext } from '../../elements/Action/ActionRoot'; +import { useFetch } from '../../hooks'; +import { CreditCard } from '../../icons'; +import { AddPaymentSource } from './AddPaymentSource'; + +const AddScreen = ({ onSuccess }: { onSuccess: () => void }) => { + const { close } = useActionContext(); + + const onAddPaymentSourceSuccess = (_: __experimental_CommercePaymentSourceResource) => { + onSuccess(); + close(); + return Promise.resolve(); + }; + + return ( + + ); +}; + +export const __experimental_PaymentSources = (props: __experimental_PaymentSourcesProps) => { + const { orgId } = props; + const { __experimental_commerce } = useClerk(); + + const { data, isLoading, revalidate } = useFetch(__experimental_commerce?.getPaymentSources, { orgId }); + const { data: paymentSources } = data || { data: [] }; + + return ( + + + + {isLoading ? ( + ({ + width: '100%', + minHeight: t.sizes.$60, + })} + > + + + ) : ( + <> + {paymentSources.map(paymentSource => ( + + + {paymentSource.paymentMethod === 'card' && ( + + )} + ({ color: t.colors.$colorText, textTransform: 'capitalize' })} + truncate + > + {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} + + ({ color: t.colors.$colorTextSecondary })} + variant='caption' + truncate + > + {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'} + + {paymentSource.isDefault && } + {paymentSource.status === 'expired' && ( + + )} + + + + ))} + + + + + + + + + + )} + + + + ); +}; + +const PaymentSourceMenu = ({ paymentSource }: { paymentSource: __experimental_CommercePaymentSourceResource }) => { + const card = useCardState(); + const { __experimental_commerce } = useClerk(); + + const actions = [ + { + label: localizationKeys('userProfile.__experimental_billingPage.paymentSourcesSection.actionLabel__remove'), + isDestructive: true, + onClick: () => { + // TODO: Implement remove payment source + }, + }, + ]; + + return ; +}; diff --git a/packages/clerk-js/src/ui/components/Commerce/index.ts b/packages/clerk-js/src/ui/components/Commerce/index.ts new file mode 100644 index 00000000000..74008535779 --- /dev/null +++ b/packages/clerk-js/src/ui/components/Commerce/index.ts @@ -0,0 +1,2 @@ +export * from './AddPaymentSource'; +export * from './PaymentSources'; diff --git a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx index 05a217f280b..3da5433753a 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx @@ -11,6 +11,7 @@ import { useCardState, withCardStateProvider, } from '../../elements'; +import { __experimental_PaymentSources } from '../Commerce'; import { __experimental_PricingTable } from '../PricingTable'; export const BillingPage = withCardStateProvider(() => { @@ -56,7 +57,9 @@ export const BillingPage = withCardStateProvider(() => { Invoices - Payment Sources + + <__experimental_PaymentSources /> + diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 87c6b76844f..72c65ea30bd 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -13,6 +13,7 @@ export const enUS: LocalizationResource = { backButton: 'Back', badge__currentPlan: 'Current Plan', badge__default: 'Default', + badge__expired: 'Expired', badge__otherImpersonatorDevice: 'Other impersonator device', badge__primary: 'Primary', badge__requiresAction: 'Requires action', @@ -668,6 +669,14 @@ export const enUS: LocalizationResource = { headerTitle__plans: 'Plans', }, title: 'Billing & Payments', + paymentSourcesSection: { + title: 'Available options', + addButton: 'Add new payment source', + saveButton: 'Save Payment Information', + cancelButton: 'Cancel', + actionLabel__default: 'Make default', + actionLabel__remove: 'Remove', + }, }, backupCodePage: { actionLabel__copied: 'Copied!', diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index a992b7dfbef..1a07463b668 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -776,6 +776,7 @@ export type UserVerificationTheme = Theme; export type WaitlistTheme = Theme; export type PricingTableTheme = Theme; export type CheckoutTheme = Theme; +export type PaymentSourcesTheme = Theme; export type Appearance = T & { /** @@ -830,4 +831,8 @@ export type Appearance = T & { * Theme overrides that only apply to the `` component */ checkout?: T; + /** + * Theme overrides that only apply to the `` component + */ + paymentSources?: T; }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 005099b9b9e..c4b77290f27 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -5,6 +5,7 @@ import type { OrganizationListTheme, OrganizationProfileTheme, OrganizationSwitcherTheme, + PaymentSourcesTheme, PricingTableTheme, SignInTheme, SignUpTheme, @@ -1510,6 +1511,11 @@ export type __experimental_CheckoutProps = { onSubscriptionComplete?: () => void; }; +export type __experimental_PaymentSourcesProps = { + appearance?: PaymentSourcesTheme; + orgId?: string; +}; + export interface HandleEmailLinkVerificationParams { /** * Full URL or path to navigate after successful magic link verification diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 9a25b09cde9..a56bb870904 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -4,7 +4,12 @@ import type { ClerkResource } from './resource'; export interface __experimental_CommerceNamespace { __experimental_billing: __experimental_CommerceBillingNamespace; - getPaymentSources: () => Promise>; + getPaymentSources: ( + params: __experimental_GetPaymentSourcesParams, + ) => Promise>; + initializePaymentSource: ( + params: __experimental_InitializePaymentSourceParams, + ) => Promise<__experimental_CommerceInitializedPaymentSourceResource>; addPaymentSource: ( params: __experimental_AddPaymentSourceParams, ) => Promise<__experimental_CommercePaymentSourceResource>; @@ -62,17 +67,34 @@ export interface __experimental_CommerceFeatureResource extends ClerkResource { avatarUrl: string; } +export type __experimental_CommercePaymentSourceStatus = 'active' | 'expired' | 'pending' | 'disconnected'; + +export interface __experimental_InitializePaymentSourceParams { + gateway: 'stripe' | 'paypal'; +} + export interface __experimental_AddPaymentSourceParams { gateway: 'stripe' | 'paypal'; paymentMethod: string; paymentToken: string; } +export interface __experimental_GetPaymentSourcesParams { + orgId?: string; +} + export interface __experimental_CommercePaymentSourceResource extends ClerkResource { id: string; last4: string; paymentMethod: string; cardType: string; + isDefault: boolean; + status: __experimental_CommercePaymentSourceStatus; +} + +export interface __experimental_CommerceInitializedPaymentSourceResource extends ClerkResource { + externalClientSecret: string; + externalGatewayId: string; } export interface __experimental_CommerceInvoiceResource extends ClerkResource { diff --git a/packages/types/src/elementIds.ts b/packages/types/src/elementIds.ts index 6bca990b02a..9b9872070c7 100644 --- a/packages/types/src/elementIds.ts +++ b/packages/types/src/elementIds.ts @@ -38,7 +38,8 @@ export type ProfileSectionId = | 'organizationProfile' | 'organizationDanger' | 'organizationDomains' - | 'manageVerifiedDomains'; + | 'manageVerifiedDomains' + | 'paymentSources'; export type ProfilePageId = 'account' | 'security' | 'organizationGeneral' | 'organizationMembers' | 'billing'; export type UserPreviewId = 'userButton' | 'personalWorkspace'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 07f9dc43251..8b9e7b941c9 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -3,6 +3,7 @@ */ import type { + __experimental_CommercePaymentSourceStatus, __experimental_CommerceSubscriptionPlanPeriod, __experimental_CommerceSubscriptionStatus, } from './commerce'; @@ -625,6 +626,13 @@ export interface __experimental_CommercePaymentSourceJSON extends ClerkResourceJ last4: string; payment_method: string; card_type: string; + status: __experimental_CommercePaymentSourceStatus; +} + +export interface __experimental_CommerceInitializedPaymentSourceJSON extends ClerkResourceJSON { + object: 'commerce_payment_source_initialize'; + external_client_secret: string; + external_gateway_id: string; } export interface __experimental_CommerceInvoiceJSON extends ClerkResourceJSON { diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 3a5f816f67c..7b8935e0633 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -88,6 +88,7 @@ type _LocalizationResource = { badge__requiresAction: LocalizationValue; badge__you: LocalizationValue; badge__currentPlan: LocalizationValue; + badge__expired: LocalizationValue; footerPageLink__help: LocalizationValue; footerPageLink__privacy: LocalizationValue; footerPageLink__terms: LocalizationValue; @@ -661,6 +662,14 @@ type _LocalizationResource = { headerTitle__invoices: LocalizationValue; headerTitle__paymentSources: LocalizationValue; }; + paymentSourcesSection: { + title: LocalizationValue; + addButton: LocalizationValue; + saveButton: LocalizationValue; + cancelButton: LocalizationValue; + actionLabel__default: LocalizationValue; + actionLabel__remove: LocalizationValue; + }; }; }; userButton: { From 0302dbd24a5afcc437958ac715c726f7c116aca7 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Sun, 30 Mar 2025 11:03:45 -0700 Subject: [PATCH 02/10] wire up remove payment source --- .../core/resources/CommercePaymentSource.ts | 14 ++++++++- .../ui/components/Commerce/PaymentSources.tsx | 30 +++++++++++++++---- packages/types/src/commerce.ts | 1 + 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/clerk-js/src/core/resources/CommercePaymentSource.ts b/packages/clerk-js/src/core/resources/CommercePaymentSource.ts index 5fc90621a96..bcd0aca4bd0 100644 --- a/packages/clerk-js/src/core/resources/CommercePaymentSource.ts +++ b/packages/clerk-js/src/core/resources/CommercePaymentSource.ts @@ -4,9 +4,10 @@ import type { __experimental_CommercePaymentSourceJSON, __experimental_CommercePaymentSourceResource, __experimental_CommercePaymentSourceStatus, + DeletedObjectJSON, } from '@clerk/types'; -import { BaseResource } from './internal'; +import { BaseResource, DeletedObject } from './internal'; export class __experimental_CommercePaymentSource extends BaseResource @@ -37,6 +38,17 @@ export class __experimental_CommercePaymentSource this.status = data.status; return this; } + + public async remove() { + const json = ( + await BaseResource._fetch({ + path: `/me/commerce/payment_sources/${this.id}`, + method: 'DELETE', + }) + )?.response as unknown as DeletedObjectJSON; + + return new DeletedObject(json); + } } export class __experimental_CommerceInitializedPaymentSource diff --git a/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx b/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx index 9d6b149988a..c8940fd5568 100644 --- a/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx +++ b/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx @@ -7,6 +7,7 @@ import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; import { useFetch } from '../../hooks'; import { CreditCard } from '../../icons'; +import { handleError } from '../../utils'; import { AddPaymentSource } from './AddPaymentSource'; const AddScreen = ({ onSuccess }: { onSuccess: () => void }) => { @@ -97,7 +98,10 @@ export const __experimental_PaymentSources = (props: __experimental_PaymentSourc /> )} - + ))} @@ -121,17 +125,31 @@ export const __experimental_PaymentSources = (props: __experimental_PaymentSourc ); }; -const PaymentSourceMenu = ({ paymentSource }: { paymentSource: __experimental_CommercePaymentSourceResource }) => { +const PaymentSourceMenu = ({ + paymentSource, + revalidate, +}: { + paymentSource: __experimental_CommercePaymentSourceResource; + revalidate: () => void; +}) => { const card = useCardState(); - const { __experimental_commerce } = useClerk(); + + const removePaymentSource = async () => { + await paymentSource + .remove() + .then(() => { + revalidate(); + }) + .catch((error: Error) => { + handleError(error, [], card.setError); + }); + }; const actions = [ { label: localizationKeys('userProfile.__experimental_billingPage.paymentSourcesSection.actionLabel__remove'), isDestructive: true, - onClick: () => { - // TODO: Implement remove payment source - }, + onClick: removePaymentSource, }, ]; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index a56bb870904..8dae928f750 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -90,6 +90,7 @@ export interface __experimental_CommercePaymentSourceResource extends ClerkResou cardType: string; isDefault: boolean; status: __experimental_CommercePaymentSourceStatus; + remove: () => Promise; } export interface __experimental_CommerceInitializedPaymentSourceResource extends ClerkResource { From f67730df8da129473276fc9fa4fbf60a388b0e04 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 31 Mar 2025 08:32:06 -0700 Subject: [PATCH 03/10] remove payment source --- .../ui/components/Commerce/PaymentSources.tsx | 159 +++++++++++------- packages/localizations/src/en-US.ts | 7 + packages/types/src/commerce.ts | 2 +- packages/types/src/localization.ts | 6 + 4 files changed, 114 insertions(+), 60 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx b/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx index c8940fd5568..e0417bf81b2 100644 --- a/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx +++ b/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx @@ -1,6 +1,8 @@ import { useClerk } from '@clerk/shared/react'; import type { __experimental_CommercePaymentSourceResource, __experimental_PaymentSourcesProps } from '@clerk/types'; +import { Fragment, useRef } from 'react'; +import { RemoveResourceForm } from '../../common'; import { Badge, descriptors, Flex, Icon, localizationKeys, Spinner, Text } from '../../customizables'; import { ProfileSection, ThreeDotsMenu, useCardState } from '../../elements'; import { Action } from '../../elements/Action'; @@ -27,6 +29,57 @@ const AddScreen = ({ onSuccess }: { onSuccess: () => void }) => { ); }; +const RemoveScreen = ({ + paymentSource, + revalidate, +}: { + paymentSource: __experimental_CommercePaymentSourceResource; + revalidate: () => void; +}) => { + const { close } = useActionContext(); + const card = useCardState(); + const ref = useRef( + `${paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} ${paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'}`, + ); + + if (!ref.current) { + return null; + } + + const removePaymentSource = async () => { + await paymentSource.remove().catch((error: Error) => { + handleError(error, [], card.setError); + }); + }; + + return ( + { + revalidate(); + close(); + }} + onReset={close} + /> + ); +}; + export const __experimental_PaymentSources = (props: __experimental_PaymentSourcesProps) => { const { orgId } = props; const { __experimental_commerce } = useClerk(); @@ -62,47 +115,52 @@ export const __experimental_PaymentSources = (props: __experimental_PaymentSourc ) : ( <> {paymentSources.map(paymentSource => ( - - - {paymentSource.paymentMethod === 'card' && ( - - )} - ({ color: t.colors.$colorText, textTransform: 'capitalize' })} - truncate - > - {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} - - ({ color: t.colors.$colorTextSecondary })} - variant='caption' - truncate + + + - {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'} - - {paymentSource.isDefault && } - {paymentSource.status === 'expired' && ( - + )} + ({ color: t.colors.$colorText, textTransform: 'capitalize' })} + truncate + > + {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} + + ({ color: t.colors.$colorTextSecondary })} + variant='caption' + truncate + > + {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'} + + {paymentSource.isDefault && } + {paymentSource.status === 'expired' && ( + + )} + + + + + + + - )} - - - + + + ))} void; -}) => { - const card = useCardState(); - - const removePaymentSource = async () => { - await paymentSource - .remove() - .then(() => { - revalidate(); - }) - .catch((error: Error) => { - handleError(error, [], card.setError); - }); - }; +const PaymentSourceMenu = ({ paymentSource }: { paymentSource: __experimental_CommercePaymentSourceResource }) => { + const { open } = useActionContext(); const actions = [ { label: localizationKeys('userProfile.__experimental_billingPage.paymentSourcesSection.actionLabel__remove'), isDestructive: true, - onClick: removePaymentSource, + onClick: () => open(`remove-${paymentSource.id}`), }, ]; diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 72c65ea30bd..037fdd1cc72 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -676,6 +676,13 @@ export const enUS: LocalizationResource = { cancelButton: 'Cancel', actionLabel__default: 'Make default', actionLabel__remove: 'Remove', + removeResource: { + title: 'Remove payment source', + messageLine1: '{{identifier}} will be removed from this account.', + messageLine2: + 'You will no longer be able to use this payment source and any recurring subscriptions dependent on it will no longer work.', + successMessage: '{{paymentSource}} has been removed from your account.', + }, }, }, backupCodePage: { diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 8dae928f750..6cb0aa336b8 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -67,7 +67,7 @@ export interface __experimental_CommerceFeatureResource extends ClerkResource { avatarUrl: string; } -export type __experimental_CommercePaymentSourceStatus = 'active' | 'expired' | 'pending' | 'disconnected'; +export type __experimental_CommercePaymentSourceStatus = 'active' | 'expired' | 'disconnected'; export interface __experimental_InitializePaymentSourceParams { gateway: 'stripe' | 'paypal'; diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 7b8935e0633..61036e5fa09 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -669,6 +669,12 @@ type _LocalizationResource = { cancelButton: LocalizationValue; actionLabel__default: LocalizationValue; actionLabel__remove: LocalizationValue; + removeResource: { + title: LocalizationValue; + messageLine1: LocalizationValue; + messageLine2: LocalizationValue; + successMessage: LocalizationValue; + }; }; }; }; From 9998ce58b68625b6715a2c9359042476407cccfb Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 31 Mar 2025 09:01:42 -0700 Subject: [PATCH 04/10] changeset --- .changeset/spicy-taxes-kick.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/spicy-taxes-kick.md diff --git a/.changeset/spicy-taxes-kick.md b/.changeset/spicy-taxes-kick.md new file mode 100644 index 00000000000..a970786c2f4 --- /dev/null +++ b/.changeset/spicy-taxes-kick.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Add payment source section to `UserProfile` From 1c0157b3e3fedf844e2c9dc5879d747bd4c5be61 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 31 Mar 2025 10:00:13 -0700 Subject: [PATCH 05/10] adjust bundles --- packages/clerk-js/bundlewatch.config.json | 10 ++++++---- packages/clerk-js/rspack.config.js | 6 +++--- .../src/ui/components/Checkout/CheckoutForm.tsx | 2 +- .../{Commerce => PaymentSources}/AddPaymentSource.tsx | 0 .../{Commerce => PaymentSources}/PaymentSources.tsx | 0 .../components/{Commerce => PaymentSources}/index.ts | 0 .../src/ui/components/UserProfile/BillingPage.tsx | 2 +- 7 files changed, 11 insertions(+), 9 deletions(-) rename packages/clerk-js/src/ui/components/{Commerce => PaymentSources}/AddPaymentSource.tsx (100%) rename packages/clerk-js/src/ui/components/{Commerce => PaymentSources}/PaymentSources.tsx (100%) rename packages/clerk-js/src/ui/components/{Commerce => PaymentSources}/index.ts (100%) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index e6017c52293..bb826171c5f 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "582.6kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "80kB" }, + { "path": "./dist/clerk.js", "maxSize": "584.5kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "80.1kB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "96KB" }, { "path": "./dist/vendors*.js", "maxSize": "30KB" }, @@ -19,9 +19,11 @@ { "path": "./dist/onetap*.js", "maxSize": "1KB" }, { "path": "./dist/waitlist*.js", "maxSize": "1.3KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "5.9KB" }, - { "path": "./dist/pricingTable*.js", "maxSize": "5.5KB" }, - { "path": "./dist/checkout*.js", "maxSize": "9KB" }, + { "path": "./dist/pricingTable*.js", "maxSize": "5KB" }, + { "path": "./dist/checkout*.js", "maxSize": "3KB" }, + { "path": "./dist/paymentSources*.js", "maxSize": "8KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "1KB" }, + { "path": "./dist/op-billing-page*.js", "maxSize": "1KB" }, { "path": "./dist/sessionTasks*.js", "maxSize": "1KB" } ] } diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index ba490c9eadc..d94c135932f 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -93,12 +93,12 @@ const common = ({ mode, disableRHC = false }) => { name: 'signup', test: module => module.resource && module.resource.includes('/ui/components/SignUp'), }, - checkout: { + paymentSources: { minChunks: 1, - name: 'checkout', + name: 'paymentSources', test: module => module.resource && - (module.resource.includes('/ui/components/Checkout') || + (module.resource.includes('/ui/components/PaymentSources') || // Include `@stripe/react-stripe-js` and `@stripe/stripe-js` in the checkout chunk module.resource.includes('/node_modules/@stripe')), }, diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 8ef12213cc1..bd4fc726402 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -14,7 +14,7 @@ import { useFetch } from '../../hooks'; import { ArrowUpDown, CreditCard } from '../../icons'; import { animations } from '../../styledSystem'; import { handleError } from '../../utils'; -import { AddPaymentSource } from '../Commerce'; +import { AddPaymentSource } from '../PaymentSources'; export const CheckoutForm = ({ checkout, diff --git a/packages/clerk-js/src/ui/components/Commerce/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx similarity index 100% rename from packages/clerk-js/src/ui/components/Commerce/AddPaymentSource.tsx rename to packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx diff --git a/packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx similarity index 100% rename from packages/clerk-js/src/ui/components/Commerce/PaymentSources.tsx rename to packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx diff --git a/packages/clerk-js/src/ui/components/Commerce/index.ts b/packages/clerk-js/src/ui/components/PaymentSources/index.ts similarity index 100% rename from packages/clerk-js/src/ui/components/Commerce/index.ts rename to packages/clerk-js/src/ui/components/PaymentSources/index.ts diff --git a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx index 3da5433753a..824dbce854a 100644 --- a/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx +++ b/packages/clerk-js/src/ui/components/UserProfile/BillingPage.tsx @@ -11,7 +11,7 @@ import { useCardState, withCardStateProvider, } from '../../elements'; -import { __experimental_PaymentSources } from '../Commerce'; +import { __experimental_PaymentSources } from '../PaymentSources'; import { __experimental_PricingTable } from '../PricingTable'; export const BillingPage = withCardStateProvider(() => { From fbb3bb82954a27b5e255ce9e2f549b1c5a134f74 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 31 Mar 2025 16:25:27 -0700 Subject: [PATCH 06/10] address feedback --- .../ui/components/Checkout/CheckoutForm.tsx | 9 +- .../PaymentSources/AddPaymentSource.tsx | 174 ++++++++---------- .../PaymentSources/PaymentSources.tsx | 18 +- packages/localizations/src/en-US.ts | 6 +- packages/types/src/localization.ts | 6 +- 5 files changed, 103 insertions(+), 110 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index bd4fc726402..669d1f39745 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -8,7 +8,7 @@ import type { } from '@clerk/types'; import { useCallback, useMemo, useState } from 'react'; -import { Box, Button, Col, descriptors, Flex, Form, Icon, Text } from '../../customizables'; +import { Box, Button, Col, descriptors, Flex, Form, Icon, localizationKeys, Text } from '../../customizables'; import { Alert, Disclosure, Divider, Drawer, LineItems, Select, SelectButton, SelectOptionList } from '../../elements'; import { useFetch } from '../../hooks'; import { ArrowUpDown, CreditCard } from '../../icons'; @@ -182,7 +182,12 @@ const CheckoutFormElements = ({ checkout={checkout} onSuccess={onAddPaymentSourceSuccess} onExpand={didExpandStripePaymentMethods} - submitButtonText={`Pay ${(checkout.totals.totalDueNow || checkout.totals.grandTotal).currencySymbol}${(checkout.totals.totalDueNow || checkout.totals.grandTotal).amountFormatted}`} + submitLabel={localizationKeys( + 'userProfile.__experimental_billingPage.paymentSourcesSection.formButtonPrimary__pay', + { + amount: `${(checkout.totals.totalDueNow || checkout.totals.grandTotal).currencySymbol}${(checkout.totals.totalDueNow || checkout.totals.grandTotal).amountFormatted}`, + }, + )} /> diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index b921418e4b0..525a6cd71d0 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -11,16 +11,17 @@ import { loadStripe } from '@stripe/stripe-js'; import { useEffect, useRef, useState } from 'react'; import { useEnvironment } from '../../contexts'; -import { Button, descriptors, Flex, Form, useAppearance } from '../../customizables'; -import { Alert } from '../../elements'; +import { Button, descriptors, localizationKeys, useAppearance } from '../../customizables'; +import { Alert, Form, FormButtons, FormContainer } from '../../elements'; import { useFetch } from '../../hooks/useFetch'; +import type { LocalizationKey } from '../../localization'; import { animations } from '../../styledSystem'; import { handleError, normalizeColorString } from '../../utils'; type AddPaymentSourceProps = { onSuccess: (paymentSource: __experimental_CommercePaymentSourceResource) => Promise; checkout?: __experimental_CommerceCheckoutResource; - submitButtonText?: string; + submitLabel?: LocalizationKey; cancelAction?: () => void; cancelButtonText?: string; onExpand?: () => void; @@ -29,7 +30,7 @@ type AddPaymentSourceProps = { type AddPaymentSourceFormProps = Omit; export const AddPaymentSource = (props: AddPaymentSourceProps) => { - const { checkout, submitButtonText, onSuccess, onExpand, cancelAction, cancelButtonText } = props; + const { checkout, submitLabel, onSuccess, onExpand, cancelAction, cancelButtonText } = props; const { __experimental_commerce } = useClerk(); const { __experimental_commerceSettings } = useEnvironment(); @@ -89,7 +90,7 @@ export const AddPaymentSource = (props: AddPaymentSourceProps) => { options={{ clientSecret: externalClientSecret, appearance: elementsAppearance }} > { }; const AddPaymentSourceForm = ({ - submitButtonText, + submitLabel, onSuccess, onExpand, cancelAction, @@ -110,7 +111,6 @@ const AddPaymentSourceForm = ({ const stripe = useStripe(); const elements = useElements(); const [collapsed, setCollapsed] = useState(true); - const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(); useEffect(() => { @@ -124,7 +124,6 @@ const AddPaymentSourceForm = ({ if (!stripe || !elements) { return; } - setIsSubmitting(true); setSubmitError(undefined); try { @@ -145,91 +144,67 @@ const AddPaymentSourceForm = ({ paymentToken: setupIntent.payment_method as string, }); - await onSuccess(paymentSource); + void onSuccess(paymentSource); } catch (error) { - handleError(error, [], setSubmitError); - } finally { - setIsSubmitting(false); + void handleError(error, [], setSubmitError); } }; return ( -
({ - display: 'flex', - flexDirection: 'column', - rowGap: t.space.$3, - })} + - {collapsed ? ( - <> - - - - {cancelAction ? ( + ({ + display: 'flex', + flexDirection: 'column', + rowGap: t.space.$3, + })} + > + {collapsed ? ( + <> + - ) : null} - - ) : ( - <> - - {submitError && ( - ({ - animation: `${animations.textInBig} ${t.transitionDuration.$slow}`, - })} + + {cancelAction ? ( - - - )} - + + ) : ( + <> + + {submitError && ( + ({ + animation: `${animations.textInBig} ${t.transitionDuration.$slow}`, + })} + > + {typeof submitError === 'string' ? submitError : submitError.message} + + )} + + + )} + +
); }; diff --git a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx index e0417bf81b2..71472239442 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx @@ -47,9 +47,12 @@ const RemoveScreen = ({ } const removePaymentSource = async () => { - await paymentSource.remove().catch((error: Error) => { - handleError(error, [], card.setError); - }); + await paymentSource + .remove() + .then(revalidate) + .catch((error: Error) => { + handleError(error, [], card.setError); + }); }; return ( @@ -71,10 +74,7 @@ const RemoveScreen = ({ }, )} deleteResource={removePaymentSource} - onSuccess={() => { - revalidate(); - close(); - }} + onSuccess={close} onReset={close} /> ); @@ -165,9 +165,7 @@ export const __experimental_PaymentSources = (props: __experimental_PaymentSourc diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 037fdd1cc72..3a3013d7d86 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -671,11 +671,13 @@ export const enUS: LocalizationResource = { title: 'Billing & Payments', paymentSourcesSection: { title: 'Available options', - addButton: 'Add new payment source', - saveButton: 'Save Payment Information', + add: 'Add new payment source', + addSubtitle: 'Add a new payment source to your account.', cancelButton: 'Cancel', actionLabel__default: 'Make default', actionLabel__remove: 'Remove', + formButtonPrimary__add: 'Add Payment Method', + formButtonPrimary__pay: 'Pay {{amount}}', removeResource: { title: 'Remove payment source', messageLine1: '{{identifier}} will be removed from this account.', diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 61036e5fa09..0e45ebc5b07 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -664,11 +664,13 @@ type _LocalizationResource = { }; paymentSourcesSection: { title: LocalizationValue; - addButton: LocalizationValue; - saveButton: LocalizationValue; + add: LocalizationValue; + addSubtitle: LocalizationValue; cancelButton: LocalizationValue; actionLabel__default: LocalizationValue; actionLabel__remove: LocalizationValue; + formButtonPrimary__add: LocalizationValue; + formButtonPrimary__pay: LocalizationValue; removeResource: { title: LocalizationValue; messageLine1: LocalizationValue; From 55e6fd48325d4c7ec7e7abc9617538b91ce5064a Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Mon, 31 Mar 2025 19:51:23 -0700 Subject: [PATCH 07/10] cleaned up add payment source form --- .../PaymentSources/AddPaymentSource.tsx | 49 ++++++- .../PaymentSources/PaymentSources.tsx | 136 ++++++++---------- .../clerk-js/src/ui/elements/FormButtons.tsx | 15 +- 3 files changed, 109 insertions(+), 91 deletions(-) diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index 525a6cd71d0..d93a83ea2f1 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -11,7 +11,7 @@ import { loadStripe } from '@stripe/stripe-js'; import { useEffect, useRef, useState } from 'react'; import { useEnvironment } from '../../contexts'; -import { Button, descriptors, localizationKeys, useAppearance } from '../../customizables'; +import { Button, descriptors, Flex, localizationKeys, Spinner, useAppearance } from '../../customizables'; import { Alert, Form, FormButtons, FormContainer } from '../../elements'; import { useFetch } from '../../hooks/useFetch'; import type { LocalizationKey } from '../../localization'; @@ -27,7 +27,9 @@ type AddPaymentSourceProps = { onExpand?: () => void; }; -type AddPaymentSourceFormProps = Omit; +type AddPaymentSourceFormProps = { + isCheckout?: boolean; +} & Omit; export const AddPaymentSource = (props: AddPaymentSourceProps) => { const { checkout, submitLabel, onSuccess, onExpand, cancelAction, cancelButtonText } = props; @@ -61,7 +63,7 @@ export const AddPaymentSource = (props: AddPaymentSourceProps) => { // if we have a checkout, we can use the checkout's client secret and gateway id // otherwise, we need to initialize a new payment source - const { data: initializedPaymentSource } = useFetch( + const { data: initializedPaymentSource, invalidate } = useFetch( !checkout ? __experimental_commerce.initializePaymentSource : undefined, { gateway: 'stripe', @@ -82,7 +84,30 @@ export const AddPaymentSource = (props: AddPaymentSourceProps) => { } }, [externalGatewayId, __experimental_commerceSettings]); - if (!stripe || !externalClientSecret) return null; + // invalidate the initialized payment source when the component unmounts + useEffect(() => { + return invalidate; + }, [invalidate]); + + if (!stripe || !externalClientSecret) { + return ( + ({ + width: '100%', + minHeight: t.sizes.$60, + })} + > + + + ); + } return ( { onExpand={onExpand} cancelAction={cancelAction} cancelButtonText={cancelButtonText} + isCheckout={!!checkout} /> ); @@ -106,6 +132,7 @@ const AddPaymentSourceForm = ({ onExpand, cancelAction, cancelButtonText, + isCheckout, }: AddPaymentSourceFormProps) => { const { __experimental_commerce } = useClerk(); const stripe = useStripe(); @@ -135,7 +162,7 @@ const AddPaymentSourceForm = ({ redirect: 'if_required', }); if (error) { - return handleError(new Error(error.message), [], setSubmitError); + return; // just return, since stripe will handle the error } const paymentSource = await __experimental_commerce.addPaymentSource({ @@ -152,8 +179,14 @@ const AddPaymentSourceForm = ({ return ( )} diff --git a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx index 71472239442..5e6d0676ebb 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/PaymentSources.tsx @@ -3,7 +3,7 @@ import type { __experimental_CommercePaymentSourceResource, __experimental_Payme import { Fragment, useRef } from 'react'; import { RemoveResourceForm } from '../../common'; -import { Badge, descriptors, Flex, Icon, localizationKeys, Spinner, Text } from '../../customizables'; +import { Badge, Flex, Icon, localizationKeys, Text } from '../../customizables'; import { ProfileSection, ThreeDotsMenu, useCardState } from '../../elements'; import { Action } from '../../elements/Action'; import { useActionContext } from '../../elements/Action/ActionRoot'; @@ -84,7 +84,7 @@ export const __experimental_PaymentSources = (props: __experimental_PaymentSourc const { orgId } = props; const { __experimental_commerce } = useClerk(); - const { data, isLoading, revalidate } = useFetch(__experimental_commerce?.getPaymentSources, { orgId }); + const { data, revalidate } = useFetch(__experimental_commerce?.getPaymentSources, { orgId }); const { data: paymentSources } = data || { data: [] }; return ( @@ -96,85 +96,65 @@ export const __experimental_PaymentSources = (props: __experimental_PaymentSourc > - {isLoading ? ( - ({ - width: '100%', - minHeight: t.sizes.$60, - })} - > - - - ) : ( - <> - {paymentSources.map(paymentSource => ( - - - - {paymentSource.paymentMethod === 'card' && ( - - )} - ({ color: t.colors.$colorText, textTransform: 'capitalize' })} - truncate - > - {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} - - ({ color: t.colors.$colorTextSecondary })} - variant='caption' - truncate - > - {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'} - - {paymentSource.isDefault && } - {paymentSource.status === 'expired' && ( - - )} - - - + {paymentSources.map(paymentSource => ( + + + + {paymentSource.paymentMethod === 'card' && ( + + )} + ({ color: t.colors.$colorText, textTransform: 'capitalize' })} + truncate + > + {paymentSource.paymentMethod === 'card' ? paymentSource.cardType : paymentSource.paymentMethod} + + ({ color: t.colors.$colorTextSecondary })} + variant='caption' + truncate + > + {paymentSource.paymentMethod === 'card' ? `⋯ ${paymentSource.last4}` : '-'} + + {paymentSource.isDefault && } + {paymentSource.status === 'expired' && ( + + )} + + + - - - - - - - ))} - - - - - - + + + - - )} + + ))} + + + + + + + + diff --git a/packages/clerk-js/src/ui/elements/FormButtons.tsx b/packages/clerk-js/src/ui/elements/FormButtons.tsx index c580a78b991..e343f62e92b 100644 --- a/packages/clerk-js/src/ui/elements/FormButtons.tsx +++ b/packages/clerk-js/src/ui/elements/FormButtons.tsx @@ -8,12 +8,13 @@ import { Form } from './Form'; type FormButtonsProps = PropsOfComponent & { isDisabled?: boolean; onReset?: () => void; + hideReset?: boolean; submitLabel?: LocalizationKey; resetLabel?: LocalizationKey; }; export const FormButtons = (props: FormButtonsProps) => { - const { isDisabled, onReset, submitLabel, resetLabel, ...rest } = props; + const { isDisabled, onReset, submitLabel, resetLabel, hideReset, ...rest } = props; const { navigateToFlowStart } = useNavigateToFlowStart(); return ( @@ -23,11 +24,13 @@ export const FormButtons = (props: FormButtonsProps) => { localizationKey={submitLabel || localizationKeys('userProfile.formButtonPrimary__save')} {...rest} /> - + {!hideReset && ( + + )} ); }; From 921af8051b3d797ecbf4d30a3c1062cb69727369 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Tue, 1 Apr 2025 08:03:09 -0700 Subject: [PATCH 08/10] remove paymentMethod and appearance --- .../src/ui/components/PaymentSources/AddPaymentSource.tsx | 1 - packages/types/src/appearance.ts | 5 ----- packages/types/src/clerk.ts | 2 -- packages/types/src/commerce.ts | 1 - 4 files changed, 9 deletions(-) diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index d93a83ea2f1..dd003afe66a 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -167,7 +167,6 @@ const AddPaymentSourceForm = ({ const paymentSource = await __experimental_commerce.addPaymentSource({ gateway: 'stripe', - paymentMethod: 'card', // TODO(@Commerce): need to use the real value? paymentToken: setupIntent.payment_method as string, }); diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 1a07463b668..a992b7dfbef 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -776,7 +776,6 @@ export type UserVerificationTheme = Theme; export type WaitlistTheme = Theme; export type PricingTableTheme = Theme; export type CheckoutTheme = Theme; -export type PaymentSourcesTheme = Theme; export type Appearance = T & { /** @@ -831,8 +830,4 @@ export type Appearance = T & { * Theme overrides that only apply to the `` component */ checkout?: T; - /** - * Theme overrides that only apply to the `` component - */ - paymentSources?: T; }; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 5519b548982..cedf64f508d 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -5,7 +5,6 @@ import type { OrganizationListTheme, OrganizationProfileTheme, OrganizationSwitcherTheme, - PaymentSourcesTheme, PricingTableTheme, SignInTheme, SignUpTheme, @@ -1512,7 +1511,6 @@ export type __experimental_CheckoutProps = { }; export type __experimental_PaymentSourcesProps = { - appearance?: PaymentSourcesTheme; orgId?: string; }; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 6cb0aa336b8..320b2cea108 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -75,7 +75,6 @@ export interface __experimental_InitializePaymentSourceParams { export interface __experimental_AddPaymentSourceParams { gateway: 'stripe' | 'paypal'; - paymentMethod: string; paymentToken: string; } From 23002d1641c0485d4b2b866ffdd1aa69ed03c48e Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Tue, 1 Apr 2025 08:23:25 -0700 Subject: [PATCH 09/10] cardStateProvider, some capitalization of card types --- .../components/Checkout/CheckoutComplete.tsx | 6 +- .../ui/components/Checkout/CheckoutForm.tsx | 6 +- .../PaymentSources/AddPaymentSource.tsx | 269 +++++++++--------- 3 files changed, 142 insertions(+), 139 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 057b5e5921d..b75f027e55f 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -5,6 +5,8 @@ import { Box, Button, descriptors, Heading, Icon, localizationKeys, Span, Text } import { Drawer, LineItems } from '../../elements'; import { Check } from '../../icons'; +const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); + export const CheckoutComplete = ({ checkout }: { checkout: __experimental_CommerceCheckoutResource }) => { const { setIsOpen } = useCheckoutContext(); @@ -114,7 +116,9 @@ export const CheckoutComplete = ({ checkout }: { checkout: __experimental_Commer diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 669d1f39745..37902f1afb9 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -16,6 +16,8 @@ import { animations } from '../../styledSystem'; import { handleError } from '../../utils'; import { AddPaymentSource } from '../PaymentSources'; +const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); + export const CheckoutForm = ({ checkout, onCheckoutComplete, @@ -214,7 +216,7 @@ const PaymentSourceMethods = ({ return paymentSources.map(source => { return { value: source.id, - label: `${source.cardType} ⋯ ${source.last4}`, + label: `${capitalize(source.cardType)} ⋯ ${source.last4}`, }; }); }, [paymentSources]); @@ -261,7 +263,7 @@ const PaymentSourceMethods = ({ as='span' colorScheme='body' > - {selectedPaymentSource.cardType} ⋯ {selectedPaymentSource.last4} + {capitalize(selectedPaymentSource.cardType)} ⋯ {selectedPaymentSource.last4}
)} diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index dd003afe66a..93f6a870091 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -12,7 +12,7 @@ import { useEffect, useRef, useState } from 'react'; import { useEnvironment } from '../../contexts'; import { Button, descriptors, Flex, localizationKeys, Spinner, useAppearance } from '../../customizables'; -import { Alert, Form, FormButtons, FormContainer } from '../../elements'; +import { Alert, Form, FormButtons, FormContainer, withCardStateProvider } from '../../elements'; import { useFetch } from '../../hooks/useFetch'; import type { LocalizationKey } from '../../localization'; import { animations } from '../../styledSystem'; @@ -126,153 +126,150 @@ export const AddPaymentSource = (props: AddPaymentSourceProps) => { ); }; -const AddPaymentSourceForm = ({ - submitLabel, - onSuccess, - onExpand, - cancelAction, - cancelButtonText, - isCheckout, -}: AddPaymentSourceFormProps) => { - const { __experimental_commerce } = useClerk(); - const stripe = useStripe(); - const elements = useElements(); - const [collapsed, setCollapsed] = useState(true); - const [submitError, setSubmitError] = useState(); - - useEffect(() => { - if (!collapsed) { - onExpand?.(); - } - }, [collapsed, onExpand]); +const AddPaymentSourceForm = withCardStateProvider( + ({ submitLabel, onSuccess, onExpand, cancelAction, cancelButtonText, isCheckout }: AddPaymentSourceFormProps) => { + const { __experimental_commerce } = useClerk(); + const stripe = useStripe(); + const elements = useElements(); + const [collapsed, setCollapsed] = useState(true); + const [submitError, setSubmitError] = useState(); - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!stripe || !elements) { - return; - } - setSubmitError(undefined); + useEffect(() => { + if (!collapsed) { + onExpand?.(); + } + }, [collapsed, onExpand]); - try { - const { setupIntent, error } = await stripe.confirmSetup({ - elements, - confirmParams: { - return_url: '', // TODO(@COMMERCE): need to figure this out - }, - redirect: 'if_required', - }); - if (error) { - return; // just return, since stripe will handle the error + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!stripe || !elements) { + return; } + setSubmitError(undefined); - const paymentSource = await __experimental_commerce.addPaymentSource({ - gateway: 'stripe', - paymentToken: setupIntent.payment_method as string, - }); + try { + const { setupIntent, error } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: '', // TODO(@COMMERCE): need to figure this out + }, + redirect: 'if_required', + }); + if (error) { + return; // just return, since stripe will handle the error + } - void onSuccess(paymentSource); - } catch (error) { - void handleError(error, [], setSubmitError); - } - }; + const paymentSource = await __experimental_commerce.addPaymentSource({ + gateway: 'stripe', + paymentToken: setupIntent.payment_method as string, + }); - return ( - - ({ - display: 'flex', - flexDirection: 'column', - rowGap: t.space.$3, - })} + }; + + return ( + - {collapsed ? ( - <> - - - - {cancelAction ? ( + ({ + display: 'flex', + flexDirection: 'column', + rowGap: t.space.$3, + })} + > + {collapsed ? ( + <> + - ) : null} - - ) : ( - <> - - {submitError && ( - ({ - animation: `${animations.textInBig} ${t.transitionDuration.$slow}`, - })} + + {cancelAction ? ( + + ) : null} + + ) : ( + <> + + {submitError && ( + ({ + animation: `${animations.textInBig} ${t.transitionDuration.$slow}`, + })} + > + {typeof submitError === 'string' ? submitError : submitError.message} + + )} + + + )} + + + ); + }, +); From 0dd3c5e389239f979dee9d4c400ce586415120c0 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Tue, 1 Apr 2025 08:33:13 -0700 Subject: [PATCH 10/10] bundle bump --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 35cfcfc48bf..ac9865b0bd8 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "584.5kB" }, + { "path": "./dist/clerk.js", "maxSize": "584.6kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "96KB" },